diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000000..7ef6e7583e --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,107 @@ +# Security Policy + +[English](#english) | [中文](#中文) + +## English + +### Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues, pull requests, or discussions.** + +Report them privately through GitHub's private vulnerability reporting: + +**[Report a vulnerability](https://github.com/Wei-Shaw/sub2api/security/advisories/new)** + +(Repository → **Security** tab → **Report a vulnerability**.) Only the maintainers can see the report, and the advisory becomes the place to discuss, fix, and eventually publish the issue. + +If you cannot use GitHub private vulnerability reporting, email **dev@sub2api.org** instead, with `[Security]` in the subject line. + +A useful report includes: + +- The affected component and version (release tag or commit) +- Impact: what an attacker can do and which privileges they need +- Minimal reproduction steps or a proof of concept +- Any non-default configuration required to trigger the issue +- A suggested fix, if you have one + +### Supported Versions + +Sub2API releases frequently, and fixes ship in a new release rather than being backported. + +| Version | Supported | +| ------- | --------- | +| Latest release | ✅ | +| Older releases | ❌ | + +Please confirm that the issue reproduces on the latest release or on `main` before reporting. + +### Scope + +In scope: the Sub2API backend, frontend, official Docker images, and deployment files in this repository. + +Usually out of scope: + +- Issues that require already-compromised admin credentials or server access +- Risks arising from explicitly weakening documented security settings (for example, disabling `security.url_allowlist`) +- Vulnerabilities in upstream AI providers or third-party dependencies with no Sub2API-specific impact (please report those upstream) +- Missing best-practice headers or scanner output without a demonstrated impact + +### Disclosure Process + +1. We acknowledge the report and assess its validity and severity. +2. We develop a fix privately in the advisory and may ask you to verify it. +3. We publish a release containing the fix, then publish the GitHub Security Advisory (with a CVE when applicable), crediting you unless you prefer to stay anonymous. + +Please keep details private until the advisory is published. This is a volunteer-maintained project, so response times are best effort. + +## 中文 + +### 报告漏洞 + +**请不要通过公开的 Issue、Pull Request 或 Discussion 报告安全漏洞。** + +请使用 GitHub 私密漏洞报告功能提交: + +**[提交漏洞报告](https://github.com/Wei-Shaw/sub2api/security/advisories/new)** + +(仓库 → **Security** 标签页 → **Report a vulnerability**。)报告仅维护者可见,后续的讨论、修复与公告发布都在该安全公告中进行。 + +如果无法使用 GitHub 私密漏洞报告,也可以发送邮件至 **dev@sub2api.org**,邮件标题请注明 `[Security]`。 + +报告中建议包含: + +- 受影响的组件与版本(Release tag 或 commit) +- 影响:攻击者能做什么、需要什么权限 +- 最小复现步骤或 PoC +- 触发问题所需的非默认配置 +- 修复建议(如有) + +### 支持的版本 + +Sub2API 发版频繁,安全修复随新版本发布,不向旧版本回移。 + +| 版本 | 是否支持 | +| ---- | -------- | +| 最新 Release | ✅ | +| 更早的版本 | ❌ | + +报告前请先确认问题在最新 Release 或 `main` 上仍可复现。 + +### 范围 + +范围内:本仓库中的 Sub2API 后端、前端、官方 Docker 镜像与部署文件。 + +通常不在范围内: + +- 需要事先已掌握管理员凭据或服务器权限才能利用的问题 +- 因主动关闭文档中的安全配置(如关闭 `security.url_allowlist`)而产生的风险 +- 上游 AI 服务商或第三方依赖自身的漏洞,且对 Sub2API 无特定影响(请向上游报告) +- 仅缺少最佳实践响应头或扫描器输出,但未证明实际影响 + +### 披露流程 + +1. 我们确认收到报告,并评估其有效性与严重程度。 +2. 我们在安全公告中私下开发修复,可能请你协助验证。 +3. 我们先发布包含修复的版本,再公开 GitHub 安全公告(适用时申请 CVE),并致谢报告者(如你希望匿名则不署名)。 + +在公告发布前请勿公开漏洞细节。本项目由志愿者维护,响应时间尽力而为。 diff --git a/.github/audit-exceptions.yml b/.github/audit-exceptions.yml index 6243f0fa7f..5881136f57 100644 --- a/.github/audit-exceptions.yml +++ b/.github/audit-exceptions.yml @@ -3,16 +3,16 @@ exceptions: - package: xlsx advisory: "GHSA-4r6h-8v6p-xvw6" severity: high - reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2023-30533)" - mitigation: "Load only on export; restrict export permissions and data scope" - expires_on: "2026-10-06" + reason: "Write-only: admin UsageView lazily imports xlsx solely for XLSX.write export; the advisory requires parsing a crafted file and no XLSX.read path exists (CVE-2023-30533)" + mitigation: "Never parse untrusted spreadsheets with xlsx; no patched version on npm, migrate to SheetJS CDN build or another writer before expiry" + expires_on: "2027-01-06" owner: "security@your-domain" - package: xlsx advisory: "GHSA-5pgg-2g8v-p4x9" severity: high - reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2024-22363)" - mitigation: "Load only on export; restrict export permissions and data scope" - expires_on: "2026-10-06" + reason: "Write-only: admin UsageView lazily imports xlsx solely for XLSX.write export; the advisory requires parsing a crafted file and no XLSX.read path exists (CVE-2024-22363)" + mitigation: "Never parse untrusted spreadsheets with xlsx; no patched version on npm, migrate to SheetJS CDN build or another writer before expiry" + expires_on: "2027-01-06" owner: "security@your-domain" - package: lodash advisory: "GHSA-r5fr-rjxr-66jc" diff --git a/README.md b/README.md index 8ec0b7f59c..1e8ea3f0be 100644 --- a/README.md +++ b/README.md @@ -354,8 +354,10 @@ JWT_SECRET=your_jwt_secret_here TOTP_ENCRYPTION_KEY=your_totp_key_here # Optional: Admin account -ADMIN_EMAIL=admin@example.com -ADMIN_PASSWORD=your_admin_password +# Leave empty to auto-generate a random email (login username) and password, shown in logs on first startup. +# Avoid guessable values such as admin@example.com: they are brute-force targets. +ADMIN_EMAIL= +ADMIN_PASSWORD= # Optional: Custom port SERVER_PORT=8080 @@ -404,9 +406,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api Open `http://YOUR_SERVER_IP:8080` in your browser. -If admin password was auto-generated, find it in logs: +If the admin email (login username) or password was auto-generated, find them in logs: ```bash -docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" +docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin" ``` #### Upgrade diff --git a/README_CN.md b/README_CN.md index 70eef1990d..57d92ca92a 100644 --- a/README_CN.md +++ b/README_CN.md @@ -380,8 +380,10 @@ JWT_SECRET=your_jwt_secret_here TOTP_ENCRYPTION_KEY=your_totp_key_here # 可选:管理员账号 -ADMIN_EMAIL=admin@example.com -ADMIN_PASSWORD=your_admin_password +# 留空则首次启动时自动生成随机邮箱(登录用户名)和密码,并输出到日志。 +# 请勿使用 admin@example.com 这类可被猜中的值,它们是暴力破解的目标。 +ADMIN_EMAIL= +ADMIN_PASSWORD= # 可选:自定义端口 SERVER_PORT=8080 @@ -442,9 +444,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api 在浏览器中打开 `http://你的服务器IP:8080` -如果管理员密码是自动生成的,在日志中查找: +如果管理员邮箱(登录用户名)或密码是自动生成的,在日志中查找: ```bash -docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" +docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin" ``` #### 升级 diff --git a/README_JA.md b/README_JA.md index ca94ba55d3..24ddd4dd63 100644 --- a/README_JA.md +++ b/README_JA.md @@ -355,8 +355,10 @@ JWT_SECRET=your_jwt_secret_here TOTP_ENCRYPTION_KEY=your_totp_key_here # オプション: 管理者アカウント -ADMIN_EMAIL=admin@example.com -ADMIN_PASSWORD=your_admin_password +# 空欄の場合、初回起動時にランダムなメールアドレス(ログインユーザー名)とパスワードが自動生成され、ログに出力されます。 +# admin@example.com のような推測されやすい値は総当たり攻撃の標的になるため避けてください。 +ADMIN_EMAIL= +ADMIN_PASSWORD= # オプション: カスタムポート SERVER_PORT=8080 @@ -405,9 +407,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api ブラウザで `http://YOUR_SERVER_IP:8080` を開いてください。 -管理者パスワードが自動生成された場合は、ログで確認できます: +管理者メールアドレス(ログインユーザー名)またはパスワードが自動生成された場合は、ログで確認できます: ```bash -docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" +docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin" ``` #### アップグレード diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index f2722b1339..769ed6ae79 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.12 +0.2.14 diff --git a/backend/internal/integration/e2e_user_flow_test.go b/backend/internal/integration/e2e_user_flow_test.go index 5489d0a326..9a28c66230 100644 --- a/backend/internal/integration/e2e_user_flow_test.go +++ b/backend/internal/integration/e2e_user_flow_test.go @@ -264,10 +264,11 @@ func loginTestUser(t *testing.T) string { t.Helper() // 先尝试用管理员账户登录 - adminEmail := getEnv("ADMIN_EMAIL", "admin@sub2api.local") + // 管理员邮箱不再有固定默认值(未设置时安装阶段随机生成),需同时提供邮箱与密码。 + adminEmail := getEnv("ADMIN_EMAIL", "") adminPassword := getEnv("ADMIN_PASSWORD", "") - if adminPassword == "" { + if adminEmail == "" || adminPassword == "" { // 尝试用测试用户 adminEmail = testUserEmail adminPassword = testUserPassword diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index 53e5651327..bbb007bb3e 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -376,6 +376,9 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st // url.ParseQuery already decodes values — no additional decode needed. params := make(map[string]string) for k := range values { + if !easyPayNotifyAllowedParams[k] { + return nil, fmt.Errorf("unexpected notify param: %s", k) + } params[k] = values.Get(k) } sign := params["sign"] @@ -601,3 +604,26 @@ func easyPaySign(params map[string]string, pkey string) string { func easyPayVerifySign(params map[string]string, pkey string, sign string) bool { return hmac.Equal([]byte(easyPaySign(params, pkey)), []byte(sign)) } + +// easyPayNotifyAllowedParams is the exact parameter set a genuine EasyPay +// (彩虹易支付-compatible) async notification carries. Order-creation-only +// fields (notify_url, return_url, cid, device, clientip, ...) must never +// appear in a callback: because the sign base string concatenates values +// unescaped, a signed order URL whose return_url embeds e.g. +// "trade_status=TRADE_SUCCESS" could otherwise be replayed as a forged +// payment-success notification (issue #7881). Rejecting unknown keys closes +// the whole smuggling class; genuinely paid orders rejected by an exotic +// upstream variant are still recovered by the upstream QueryOrder reconcile +// path. +var easyPayNotifyAllowedParams = map[string]bool{ + "pid": true, + "trade_no": true, + "out_trade_no": true, + "type": true, + "name": true, + "money": true, + "trade_status": true, + "param": true, + "sign": true, + "sign_type": true, +} diff --git a/backend/internal/payment/provider/easypay_notify_security_test.go b/backend/internal/payment/provider/easypay_notify_security_test.go new file mode 100644 index 0000000000..66a373e71b --- /dev/null +++ b/backend/internal/payment/provider/easypay_notify_security_test.go @@ -0,0 +1,172 @@ +package provider + +// Regression tests for the EasyPay forged-callback vulnerability +// (Wei-Shaw/sub2api issue #7875). +// +// Attack recap: the order-creation signature (visible to the payer in the +// submit.php popup URL) signs return_url among other fields, and the sign +// base string concatenates values unescaped. A client-supplied return_url +// ending in "&trade_status=TRADE_SUCCESS" therefore produced a signature +// that was byte-identical to a payment-success notification's signature +// once the callback decoded return_url partially and promoted the smuggled +// pair to a top-level parameter. Merchant-key rotation could not help +// because the attacker never needed the key. +// +// Fixes covered here: +// 1. service.CanonicalizeReturnURL drops client-supplied query parameters +// (see payment_resume_service_test.go). +// 2. VerifyNotification rejects any parameter outside the genuine async +// notify set, so smuggled order-creation fields (return_url et al.) +// fail closed even if a signed value smuggles a fake pair. + +import ( + "context" + "net/url" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/payment" +) + +// easyPayPoCProvider returns a provider with a fixed test credential set. +func easyPayPoCProvider() *EasyPay { + return &EasyPay{config: map[string]string{ + "pid": "1000", + "pkey": "MERCHANT_SECRET_KEY", + "apiBase": "https://pay.example.com", + "notifyUrl": "https://site.example.com/api/v1/payment/webhook/easypay", + "returnUrl": "https://site.example.com/payment/result", + }} +} + +// TestEasyPayNotifyRejectsForgedSignReuseCallback is the exact PoC payload +// that was accepted before the fix: the order's own submit.php signature +// replayed with trade_status smuggled out of the return_url value. +func TestEasyPayNotifyRejectsForgedSignReuseCallback(t *testing.T) { + t.Parallel() + e := easyPayPoCProvider() + + // Order creation (popup mode): return_url carries the smuggled pair as + // its last sorted inner query key, exactly as buildPaymentReturnURL + // would have produced before CanonicalizeReturnURL stripped user query. + returnURL := "https://site.example.com/payment/result?order_id=99&out_trade_no=ORDER123&status=success&trade_status=TRADE_SUCCESS" + createParams := map[string]string{ + "pid": "1000", + "type": "alipay", + "out_trade_no": "ORDER123", + "notify_url": e.config["notifyUrl"], + "return_url": returnURL, + "name": "balance recharge", + "money": "650.00", + } + sign := easyPaySign(createParams, e.config["pkey"]) + + // Forged callback: return_url encoded only up to status=success, then a + // raw &trade_status=TRADE_SUCCESS promotes it to a top-level parameter. + prefix := "https://site.example.com/payment/result?order_id=99&out_trade_no=ORDER123&status=success" + cb := url.Values{} + cb.Set("pid", "1000") + cb.Set("type", "alipay") + cb.Set("out_trade_no", "ORDER123") + cb.Set("notify_url", e.config["notifyUrl"]) + cb.Set("name", "balance recharge") + cb.Set("money", "650.00") + cb.Set("return_url", prefix) + rawCallback := cb.Encode() + "&trade_status=TRADE_SUCCESS" + "&sign=" + sign + "&sign_type=MD5" + + if _, err := e.VerifyNotification(context.Background(), rawCallback, nil); err == nil { + t.Fatal("forged sign-reuse callback must be rejected") + } +} + +// TestEasyPayNotifyRejectsOrderURLReplay covers the milder variant where the +// attacker replays the complete signed pay URL unchanged: return_url itself +// is not a legitimate notify parameter and must also be rejected. +func TestEasyPayNotifyRejectsOrderURLReplay(t *testing.T) { + t.Parallel() + e := easyPayPoCProvider() + + createParams := map[string]string{ + "pid": "1000", + "type": "alipay", + "out_trade_no": "ORDER123", + "notify_url": e.config["notifyUrl"], + "return_url": "https://site.example.com/payment/result", + "name": "balance recharge", + "money": "650.00", + } + createParams["sign"] = easyPaySign(createParams, e.config["pkey"]) + createParams["sign_type"] = signTypeMD5 + q := url.Values{} + for k, v := range createParams { + q.Set(k, v) + } + + if _, err := e.VerifyNotification(context.Background(), q.Encode(), nil); err == nil { + t.Fatal("replayed order URL must be rejected") + } +} + +// TestEasyPayNotifyAcceptsGenuineCallback locks in the legitimate notify +// contract: the canonical parameter set with a valid signature succeeds. +func TestEasyPayNotifyAcceptsGenuineCallback(t *testing.T) { + t.Parallel() + e := easyPayPoCProvider() + + params := map[string]string{ + "pid": "1000", + "trade_no": "2026100622001400000001", + "out_trade_no": "ORDER123", + "type": "alipay", + "name": "balance recharge", + "money": "650.00", + "trade_status": tradeStatusSuccess, + } + sign := easyPaySign(params, e.config["pkey"]) + params["sign"] = sign + params["sign_type"] = signTypeMD5 + q := url.Values{} + for k, v := range params { + q.Set(k, v) + } + + n, err := e.VerifyNotification(context.Background(), q.Encode(), nil) + if err != nil { + t.Fatalf("genuine callback rejected: %v", err) + } + if n.Status != payment.ProviderStatusSuccess { + t.Fatalf("status = %v, want success", n.Status) + } + if n.OrderID != "ORDER123" || n.TradeNo != "2026100622001400000001" || n.Amount != 650.00 { + t.Fatalf("unexpected notification: %+v", n) + } +} + +// TestEasyPayNotifyRejectsUnknownParam ensures any parameter outside the +// canonical notify set fails closed, including empty-valued ones that the +// signer itself would skip. +func TestEasyPayNotifyRejectsUnknownParam(t *testing.T) { + t.Parallel() + e := easyPayPoCProvider() + + params := map[string]string{ + "pid": "1000", + "trade_no": "T1", + "out_trade_no": "ORDER123", + "type": "alipay", + "name": "balance recharge", + "money": "650.00", + "trade_status": tradeStatusSuccess, + } + sign := easyPaySign(params, e.config["pkey"]) + q := url.Values{} + for k, v := range params { + q.Set(k, v) + } + q.Set("sign", sign) + q.Set("sign_type", signTypeMD5) + q.Set("device", "") // empty value: invisible to the signer, still rejected + + if _, err := e.VerifyNotification(context.Background(), q.Encode(), nil); err == nil { + t.Fatal("callback with unknown param must be rejected") + } +} diff --git a/backend/internal/repository/usage_billing_repo.go b/backend/internal/repository/usage_billing_repo.go index ce0adc2772..bfb1bb0de5 100644 --- a/backend/internal/repository/usage_billing_repo.go +++ b/backend/internal/repository/usage_billing_repo.go @@ -187,16 +187,17 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t result.BalanceOverdrafted = !sufficient } + // Key 已不存在时跳过其自身的额度/限速计数,其余结算项不受影响。 if cmd.APIKeyQuotaCost > 0 { exhausted, err := incrementUsageBillingAPIKeyQuota(ctx, tx, cmd.APIKeyID, cmd.APIKeyQuotaCost) - if err != nil { + if err != nil && !errors.Is(err, service.ErrAPIKeyNotFound) { return err } result.APIKeyQuotaExhausted = exhausted } if cmd.APIKeyRateLimitCost > 0 { - if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil { + if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil && !errors.Is(err, service.ErrAPIKeyNotFound) { return err } } diff --git a/backend/internal/repository/usage_billing_repo_integration_test.go b/backend/internal/repository/usage_billing_repo_integration_test.go index e8d4d32707..9f2f0169dc 100644 --- a/backend/internal/repository/usage_billing_repo_integration_test.go +++ b/backend/internal/repository/usage_billing_repo_integration_test.go @@ -162,6 +162,61 @@ func TestUsageBillingRepositoryApply_RequestFingerprintConflict(t *testing.T) { require.ErrorIs(t, err, service.ErrUsageBillingRequestConflict) } +func TestUsageBillingRepositoryApply_DeletedAPIKeyStillBillsBalance(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := NewUsageBillingRepository(client, integrationDB) + + user := mustCreateUser(t, client, &service.User{ + Email: fmt.Sprintf("usage-billing-deleted-key-user-%d@example.com", time.Now().UnixNano()), + PasswordHash: "hash", + Balance: 100, + }) + apiKey := mustCreateApiKey(t, client, &service.APIKey{ + UserID: user.ID, + Key: "sk-usage-billing-deleted-key-" + uuid.NewString(), + Name: "billing-deleted-key", + Quota: 50, + RateLimit5h: 50, + }) + account := mustCreateAccount(t, client, &service.Account{ + Name: "usage-billing-deleted-key-account-" + uuid.NewString(), + Type: service.AccountTypeAPIKey, + }) + + _, err := integrationDB.ExecContext(ctx, "UPDATE api_keys SET deleted_at = NOW() WHERE id = $1", apiKey.ID) + require.NoError(t, err) + + requestID := uuid.NewString() + result, err := repo.Apply(ctx, &service.UsageBillingCommand{ + RequestID: requestID, + APIKeyID: apiKey.ID, + UserID: user.ID, + AccountID: account.ID, + AccountType: service.AccountTypeAPIKey, + BalanceCost: 1.25, + APIKeyQuotaCost: 1.25, + APIKeyRateLimitCost: 1.25, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Applied) + require.False(t, result.APIKeyQuotaExhausted) + + var balance float64 + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT balance FROM users WHERE id = $1", user.ID).Scan(&balance)) + require.InDelta(t, 98.75, balance, 0.000001) + + var quotaUsed, usage5h float64 + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT quota_used, usage_5h FROM api_keys WHERE id = $1", apiKey.ID).Scan("aUsed, &usage5h)) + require.InDelta(t, 0, quotaUsed, 0.000001) + require.InDelta(t, 0, usage5h, 0.000001) + + var dedupCount int + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM usage_billing_dedup WHERE request_id = $1 AND api_key_id = $2", requestID, apiKey.ID).Scan(&dedupCount)) + require.Equal(t, 1, dedupCount) +} + func TestUsageBillingRepositoryApply_UpdatesAccountQuota(t *testing.T) { ctx := context.Background() client := testEntClient(t) diff --git a/backend/internal/repository/usage_billing_repo_unit_test.go b/backend/internal/repository/usage_billing_repo_unit_test.go index 6fe4d4fc26..40394d819c 100644 --- a/backend/internal/repository/usage_billing_repo_unit_test.go +++ b/backend/internal/repository/usage_billing_repo_unit_test.go @@ -20,6 +20,8 @@ const ( captureBatchImageHoldSQL = `(?s)UPDATE users\s+SET balance = balance\s+\+ CASE WHEN \$1 > \$2 THEN \$1 - \$2 ELSE 0 END\s+- CASE WHEN \$2 > \$1 THEN \$2 - \$1 ELSE 0 END,\s+frozen_balance = COALESCE\(frozen_balance, 0\) - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$3 AND deleted_at IS NULL AND COALESCE\(frozen_balance, 0\) >= \$1\s+RETURNING balance, frozen_balance` releaseBatchImageHoldSQL = `(?s)UPDATE users\s+SET balance = balance \+ \$1,\s+frozen_balance = COALESCE\(frozen_balance, 0\) - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$2 AND deleted_at IS NULL AND COALESCE\(frozen_balance, 0\) >= \$1\s+RETURNING balance, frozen_balance` userExistsForBillingSQL = `(?s)SELECT 1\s+FROM users\s+WHERE id = \$1 AND deleted_at IS NULL` + apiKeyQuotaIncrementSQL = `(?s)UPDATE api_keys\s+SET quota_used = quota_used \+ \$1,.*WHERE id = \$2 AND deleted_at IS NULL\s+RETURNING` + apiKeyRateLimitIncrementSQL = `(?s)UPDATE api_keys SET\s+usage_5h = .*WHERE id = \$2 AND deleted_at IS NULL` ) func TestDeductUsageBillingBalance_UsesSufficientBalanceGuard(t *testing.T) { @@ -99,6 +101,66 @@ func TestApplyUsageBillingEffects_FlagsBalanceOverdraft(t *testing.T) { require.NoError(t, mock.ExpectationsWereMet()) } +func TestApplyUsageBillingEffects_DeletedAPIKeyStillBillsBalance(t *testing.T) { + ctx := context.Background() + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectBegin() + tx, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + mock.ExpectQuery(conditionalBalanceDeductSQL). + WithArgs(10.0, int64(42)). + WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(90.0)) + mock.ExpectQuery(apiKeyQuotaIncrementSQL). + WithArgs(10.0, int64(7), service.StatusAPIKeyActive, service.StatusAPIKeyQuotaExhausted). + WillReturnError(sql.ErrNoRows) + mock.ExpectExec(apiKeyRateLimitIncrementSQL). + WithArgs(10.0, int64(7)). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectCommit() + + result := &service.UsageBillingApplyResult{Applied: true} + err = (&usageBillingRepository{}).applyUsageBillingEffects(ctx, tx, &service.UsageBillingCommand{ + UserID: 42, + APIKeyID: 7, + BalanceCost: 10, + APIKeyQuotaCost: 10, + APIKeyRateLimitCost: 10, + }, result) + require.NoError(t, err) + require.NotNil(t, result.NewBalance) + require.InDelta(t, 90.0, *result.NewBalance, 0.000001) + require.False(t, result.APIKeyQuotaExhausted) + require.NoError(t, tx.Commit()) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestApplyUsageBillingEffects_APIKeyCounterErrorStillFails(t *testing.T) { + ctx := context.Background() + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectBegin() + tx, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + mock.ExpectExec(apiKeyRateLimitIncrementSQL). + WithArgs(10.0, int64(7)). + WillReturnError(sql.ErrConnDone) + mock.ExpectRollback() + + err = (&usageBillingRepository{}).applyUsageBillingEffects(ctx, tx, &service.UsageBillingCommand{ + UserID: 42, + APIKeyID: 7, + APIKeyRateLimitCost: 10, + }, &service.UsageBillingApplyResult{Applied: true}) + require.ErrorIs(t, err, sql.ErrConnDone) + require.NoError(t, tx.Rollback()) + require.NoError(t, mock.ExpectationsWereMet()) +} + func TestDeductUsageBillingBalance_ReturnsUserNotFoundWhenNoUserUpdated(t *testing.T) { ctx := context.Background() db, mock, err := sqlmock.New() diff --git a/backend/internal/service/payment_resume_service.go b/backend/internal/service/payment_resume_service.go index 9ae62fde33..7bc76c8009 100644 --- a/backend/internal/service/payment_resume_service.go +++ b/backend/internal/service/payment_resume_service.go @@ -245,6 +245,13 @@ func CanonicalizeReturnURL(raw string, srcHost string, srcURL string) (string, e return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use http or https") } parsed.Fragment = "" + // Client-supplied query parameters are dropped: buildPaymentReturnURL adds + // its own signed set, and user-controlled pairs such as + // "trade_status=TRADE_SUCCESS" inside the signed return_url value can be + // smuggled into EasyPay-style sign base strings (values are concatenated + // unescaped), letting an order-creation signature be replayed as a forged + // payment-success callback. See issue #7881. + parsed.RawQuery = "" if parsed.Path == "" { parsed.Path = "/" } diff --git a/backend/internal/service/payment_resume_service_test.go b/backend/internal/service/payment_resume_service_test.go index 17b637fa23..54b85b4a37 100644 --- a/backend/internal/service/payment_resume_service_test.go +++ b/backend/internal/service/payment_resume_service_test.go @@ -10,6 +10,7 @@ import ( "encoding/json" "net/url" "strconv" + "strings" "testing" "time" @@ -81,12 +82,30 @@ func TestNormalizePaymentSource(t *testing.T) { func TestCanonicalizeReturnURL(t *testing.T) { t.Parallel() + // Client-supplied query and fragment are both dropped: user-controlled + // pairs inside the signed return_url value could be smuggled into + // EasyPay-style sign base strings (issue #7881). got, err := CanonicalizeReturnURL("https://example.com/payment/result?b=2#a", "example.com", "") if err != nil { t.Fatalf("CanonicalizeReturnURL returned error: %v", err) } - if got != "https://example.com/payment/result?b=2" { - t.Fatalf("CanonicalizeReturnURL = %q, want %q", got, "https://example.com/payment/result?b=2") + if got != "https://example.com/payment/result" { + t.Fatalf("CanonicalizeReturnURL = %q, want %q", got, "https://example.com/payment/result") + } +} + +func TestCanonicalizeReturnURLStripsSmuggledTradeStatus(t *testing.T) { + t.Parallel() + + got, err := CanonicalizeReturnURL( + "https://example.com/payment/result?trade_status=TRADE_SUCCESS", + "example.com", "", + ) + if err != nil { + t.Fatalf("CanonicalizeReturnURL returned error: %v", err) + } + if strings.Contains(got, "trade_status") { + t.Fatalf("CanonicalizeReturnURL kept smuggled trade_status: %q", got) } } @@ -117,8 +136,10 @@ func TestCanonicalizeReturnURLAllowsConfiguredFrontendHost(t *testing.T) { if err != nil { t.Fatalf("CanonicalizeReturnURL returned error: %v", err) } - if got != "https://app.example.com/payment/result?from=checkout" { - t.Fatalf("CanonicalizeReturnURL = %q, want %q", got, "https://app.example.com/payment/result?from=checkout") + // The host allowlist still applies, but client query parameters are + // always dropped (issue #7881: signed-value parameter smuggling). + if got != "https://app.example.com/payment/result" { + t.Fatalf("CanonicalizeReturnURL = %q, want %q", got, "https://app.example.com/payment/result") } } diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index 97603793e5..adf3d25277 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -1013,7 +1013,8 @@ func IsUpstreamBillingProbeIdentity(platform, accountType string) bool { } switch platform { case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok, - PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, + PlatformTypeSafe: return true default: return false @@ -1052,6 +1053,7 @@ var upstreamBillingProbeOfficialAPIDomains = []string{ "bigmodel.cn", "deepseek.com", "opencode.ai", + "typesafe.ai", } func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool { diff --git a/backend/internal/service/upstream_billing_probe_multiplatform_test.go b/backend/internal/service/upstream_billing_probe_multiplatform_test.go index 450cec373c..2af41cff4a 100644 --- a/backend/internal/service/upstream_billing_probe_multiplatform_test.go +++ b/backend/internal/service/upstream_billing_probe_multiplatform_test.go @@ -17,6 +17,7 @@ func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) { for _, platform := range []string{ PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, + PlatformTypeSafe, } { require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform) require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform) @@ -174,6 +175,8 @@ func TestUpstreamBillingProbeOfficialAPIBaseURLIsUnsupportedWithoutRequest(t *te {PlatformDeepseek, "https://api.deepseek.com/anthropic"}, {PlatformOpenCodeGo, "https://opencode.ai/zen/go/v1"}, {PlatformOpenCodeGo, "https://opencode.ai/zen/go"}, + {PlatformTypeSafe, "https://api.typesafe.ai"}, + {PlatformTypeSafe, "https://api.typesafe.ai/v1"}, } for i, tc := range cases { account := &Account{ @@ -217,6 +220,7 @@ func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) { require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.deepseek.com/anthropic")) require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://opencode.ai/zen/go/v1")) require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://opencode.ai/zen/go")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.typesafe.ai")) // 相似但不同的注册域不拦:中转完全可能叫 *-x.ai 之外的任何名字。 require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://relay.example/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notx.ai")) @@ -228,6 +232,7 @@ func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) { require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notmoonshot.cn/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://moonshot.cn.evil.example/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://kimi.example/v1")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://nottypesafe.ai")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notbigmodel.cn")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://deepseek.example.com")) } @@ -276,3 +281,16 @@ func TestUpstreamBillingProbeSetAccountEnabledAcceptsGrokAPIKey(t *testing.T) { err := svc.SetAccountEnabled(context.Background(), grokOAuth.ID, true) require.ErrorIs(t, err, ErrUpstreamBillingProbeAccountInvalid) } + +func TestBuildAccountForCreateAcceptsTypeSafeAPIKeyWithProbeEnabled(t *testing.T) { + enabled := true + account, err := buildAccountForCreate(&CreateAccountInput{ + Name: "typesafe", + Platform: PlatformTypeSafe, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "ts-key", "base_url": "https://api.typesafe.ai"}, + ProbeEnabled: &enabled, + }, map[string]any{}) + require.NoError(t, err) + require.Equal(t, true, account.Extra[UpstreamBillingProbeEnabledExtraKey]) +} diff --git a/backend/internal/setup/cli.go b/backend/internal/setup/cli.go index 2b323acf4a..8d4e9e419e 100644 --- a/backend/internal/setup/cli.go +++ b/backend/internal/setup/cli.go @@ -4,7 +4,6 @@ package setup import ( "bufio" "fmt" - "net/mail" "os" "regexp" "strconv" @@ -29,11 +28,6 @@ func cliValidateUsername(name string) bool { return validName.MatchString(name) && len(name) <= 63 } -func cliValidateEmail(email string) bool { - _, err := mail.ParseAddress(email) - return err == nil && len(email) <= 254 -} - func cliValidatePort(port int) bool { return port > 0 && port <= 65535 } @@ -163,9 +157,14 @@ func RunCLI() error { fmt.Println() fmt.Println("── Admin Account ──") + // 默认值使用随机邮箱,避免直接回车得到可被猜中的固定管理员用户名(issue #7850)。 + defaultAdminEmail, err := generateAdminEmail() + if err != nil { + return err + } for { - cfg.Admin.Email = promptString(reader, "Admin Email", "admin@example.com") - if cliValidateEmail(cfg.Admin.Email) { + cfg.Admin.Email = promptString(reader, "Admin Email (login username)", defaultAdminEmail) + if validateEmail(cfg.Admin.Email) { break } fmt.Println(" Invalid email format.") @@ -173,13 +172,9 @@ func RunCLI() error { for { cfg.Admin.Password = promptPassword("Admin Password") - // SECURITY: Match Web API requirement of 8 characters minimum - if len(cfg.Admin.Password) < 8 { - fmt.Println(" Password must be at least 8 characters") - continue - } - if len(cfg.Admin.Password) > 128 { - fmt.Println(" Password must be at most 128 characters") + // SECURITY: Match Web API password requirements + if err := validatePassword(cfg.Admin.Password); err != nil { + fmt.Printf(" Invalid password: %v\n", err) continue } confirm := promptPassword("Confirm Password") diff --git a/backend/internal/setup/handler.go b/backend/internal/setup/handler.go index 9207b4335c..e54945061a 100644 --- a/backend/internal/setup/handler.go +++ b/backend/internal/setup/handler.go @@ -13,6 +13,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/sysutil" "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" ) // installMutex prevents concurrent installation attempts (TOCTOU protection) @@ -83,19 +84,29 @@ func validateUsername(name string) bool { return validName.MatchString(name) && len(name) <= 63 } -// validateEmail checks if email format is valid +// validateEmail checks if email format is valid. The address must also pass the +// login endpoint's `binding:"email"` rule, otherwise the admin could be created +// with an email (e.g. "a@b" or "Name ") that can never log in. func validateEmail(email string) bool { - _, err := mail.ParseAddress(email) - return err == nil && len(email) <= 254 + if _, err := mail.ParseAddress(email); err != nil || len(email) > 254 { + return false + } + loginReq := struct { + Email string `binding:"required,email"` + }{Email: email} + return binding.Validator.ValidateStruct(&loginReq) == nil } +// maxPasswordBytes is bcrypt's input limit; longer passwords fail to hash. +const maxPasswordBytes = 72 + // validatePassword checks password strength func validatePassword(password string) error { if len(password) < 8 { return fmt.Errorf("password must be at least 8 characters") } - if len(password) > 128 { - return fmt.Errorf("password must be at most 128 characters") + if len(password) > maxPasswordBytes { + return fmt.Errorf("password must be at most %d bytes", maxPasswordBytes) } return nil } diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 963926fa4e..922ab62469 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -417,6 +417,10 @@ func createAdminUser(cfg *SetupConfig) (bool, string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + return bootstrapAdminUser(ctx, db, cfg) +} + +func bootstrapAdminUser(ctx context.Context, db *sql.DB, cfg *SetupConfig) (bool, string, error) { var totalUsers int64 if err := db.QueryRowContext(ctx, "SELECT COUNT(1) FROM users").Scan(&totalUsers); err != nil { return false, "", err @@ -430,12 +434,14 @@ func createAdminUser(cfg *SetupConfig) (bool, string, error) { return false, decision.reason, nil } - if strings.TrimSpace(cfg.Admin.Password) == "" { - password, genErr := generateSecret(16) - if genErr != nil { - return false, "", fmt.Errorf("failed to generate admin password: %w", genErr) - } - cfg.Admin.Password = password + emailGenerated, passwordGenerated, err := prepareAdminCredentials(&cfg.Admin) + if err != nil { + return false, "", err + } + if emailGenerated { + fmt.Printf("Generated admin email (login username): %s\n", cfg.Admin.Email) + } + if passwordGenerated { fmt.Printf("Generated admin password (one-time): %s\n", cfg.Admin.Password) fmt.Println("IMPORTANT: Save this password! It will not be shown again.") } @@ -473,6 +479,47 @@ func createAdminUser(cfg *SetupConfig) (bool, string, error) { return true, decision.reason, nil } +// prepareAdminCredentials fills in missing admin credentials with random values +// and rejects invalid user-supplied emails and weak passwords. It only runs +// when an admin is actually about to be created, so existing deployments are +// never affected. A random email keeps fresh installs off the well-known +// default username that brute-force scanners target (issue #7850). +func prepareAdminCredentials(admin *AdminConfig) (emailGenerated, passwordGenerated bool, err error) { + admin.Email = strings.TrimSpace(admin.Email) + if admin.Email == "" { + email, genErr := generateAdminEmail() + if genErr != nil { + return false, false, genErr + } + admin.Email = email + emailGenerated = true + } else if !validateEmail(admin.Email) { + return false, false, fmt.Errorf("invalid admin email: %q is not a valid login email", admin.Email) + } + + if strings.TrimSpace(admin.Password) == "" { + password, genErr := generateSecret(16) + if genErr != nil { + return false, false, fmt.Errorf("failed to generate admin password: %w", genErr) + } + admin.Password = password + passwordGenerated = true + } else if validateErr := validatePassword(admin.Password); validateErr != nil { + return false, false, fmt.Errorf("invalid admin password: %w", validateErr) + } + + return emailGenerated, passwordGenerated, nil +} + +// generateAdminEmail returns a random, non-guessable admin login email. +func generateAdminEmail() (string, error) { + suffix, err := generateSecret(6) + if err != nil { + return "", fmt.Errorf("failed to generate admin email: %w", err) + } + return fmt.Sprintf("admin-%s@sub2api.local", suffix), nil +} + func writeConfigFile(cfg *SetupConfig) error { // Ensure timezone has a default value tz := cfg.Timezone @@ -596,7 +643,7 @@ func AutoSetupFromEnv() error { EnableTLS: getEnvOrDefault("REDIS_ENABLE_TLS", "false") == "true", }, Admin: AdminConfig{ - Email: getEnvOrDefault("ADMIN_EMAIL", "admin@sub2api.local"), + Email: getEnvOrDefault("ADMIN_EMAIL", ""), Password: getEnvOrDefault("ADMIN_PASSWORD", ""), }, Server: ServerConfig{ diff --git a/backend/internal/setup/setup_test.go b/backend/internal/setup/setup_test.go index 7d4320ada0..33ffae25fd 100644 --- a/backend/internal/setup/setup_test.go +++ b/backend/internal/setup/setup_test.go @@ -1,15 +1,19 @@ package setup import ( + "context" "database/sql" "fmt" "os" "path/filepath" + "regexp" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin/binding" "github.com/lib/pq" "gopkg.in/yaml.v3" ) @@ -398,3 +402,235 @@ func TestDatabaseConnectionDoesNotFallbackForTargetAuthenticationError(t *testin t.Fatalf("opened databases = %v, want only configured target %q", opened, cfg.DBName) } } + +func TestPrepareAdminCredentialsGeneratesMissingValues(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: " ", Password: ""} + emailGenerated, passwordGenerated, err := prepareAdminCredentials(&admin) + if err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + if !emailGenerated || !passwordGenerated { + t.Fatalf("generated flags = (%v, %v), want (true, true)", emailGenerated, passwordGenerated) + } + if !regexp.MustCompile(`^admin-[0-9a-f]{12}@sub2api\.local$`).MatchString(admin.Email) { + t.Fatalf("generated email = %q, want admin-<12 hex>@sub2api.local", admin.Email) + } + // 生成的邮箱必须能通过登录接口的 binding:"required,email" 校验。 + loginReq := struct { + Email string `binding:"required,email"` + }{Email: admin.Email} + if err := binding.Validator.ValidateStruct(&loginReq); err != nil { + t.Fatalf("generated email %q rejected by login validator: %v", admin.Email, err) + } + if len(admin.Password) != 32 { + t.Fatalf("generated password length = %d, want 32", len(admin.Password)) + } + + other := AdminConfig{} + if _, _, err := prepareAdminCredentials(&other); err != nil { + t.Fatalf("prepareAdminCredentials() second call error = %v", err) + } + if other.Email == admin.Email { + t.Fatalf("generated emails should be random, got %q twice", admin.Email) + } +} + +func TestPrepareAdminCredentialsKeepsProvidedValues(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: "owner@example.com", Password: "a-strong-password"} + emailGenerated, passwordGenerated, err := prepareAdminCredentials(&admin) + if err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + if emailGenerated || passwordGenerated { + t.Fatalf("generated flags = (%v, %v), want (false, false)", emailGenerated, passwordGenerated) + } + if admin.Email != "owner@example.com" || admin.Password != "a-strong-password" { + t.Fatalf("provided credentials were modified: %+v", admin) + } +} + +func TestPrepareAdminCredentialsRejectsWeakPassword(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + password string + }{ + {name: "too short", password: "123456"}, + {name: "seven chars", password: "1234567"}, + {name: "exceeds bcrypt limit", password: strings.Repeat("a", 73)}, + {name: "former 128 limit", password: strings.Repeat("a", 128)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: "owner@example.com", Password: tt.password} + _, _, err := prepareAdminCredentials(&admin) + if err == nil || !strings.Contains(err.Error(), "invalid admin password") { + t.Fatalf("prepareAdminCredentials(%q) error = %v, want invalid admin password error", tt.password, err) + } + }) + } +} + +func TestPrepareAdminCredentialsRejectsUnloginableEmail(t *testing.T) { + t.Parallel() + + for _, email := range []string{"admin", "a@b", "Owner ", ""} { + t.Run(email, func(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: email, Password: "a-strong-password"} + _, _, err := prepareAdminCredentials(&admin) + if err == nil || !strings.Contains(err.Error(), "invalid admin email") { + t.Fatalf("prepareAdminCredentials(%q) error = %v, want invalid admin email error", email, err) + } + }) + } +} + +func TestPrepareAdminCredentialsTrimsProvidedEmail(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: " owner@example.com\n", Password: "a-strong-password"} + if _, _, err := prepareAdminCredentials(&admin); err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + if admin.Email != "owner@example.com" { + t.Fatalf("email = %q, want trimmed owner@example.com", admin.Email) + } +} + +func TestPrepareAdminCredentialsAcceptsBcryptMaxLengthPassword(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: "owner@example.com", Password: strings.Repeat("a", 72)} + if _, _, err := prepareAdminCredentials(&admin); err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + // 校验上限必须与 bcrypt 实际可哈希的上限一致,否则通过校验后仍会在建号时失败。 + user := service.User{} + if err := user.SetPassword(admin.Password); err != nil { + t.Fatalf("SetPassword() with max-length password error = %v", err) + } +} + +func expectAdminBootstrapCounts(mock sqlmock.Sqlmock, totalUsers, adminUsers int64) { + mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(1) FROM users")). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(totalUsers)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(1) FROM users WHERE role = $1")). + WithArgs(service.RoleAdmin). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(adminUsers)) +} + +func TestBootstrapAdminUserSkipsValidationWhenNotCreating(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + totalUsers int64 + adminUsers int64 + reason string + }{ + {name: "admin exists", totalUsers: 3, adminUsers: 1, reason: adminBootstrapReasonAdminExists}, + {name: "users exist without admin", totalUsers: 3, adminUsers: 0, reason: adminBootstrapReasonUsersExistWithoutAdmin}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer func() { _ = db.Close() }() + expectAdminBootstrapCounts(mock, tt.totalUsers, tt.adminUsers) + + // 已有部署里遗留的弱密码/非法邮箱不能阻断启动。 + cfg := &SetupConfig{Admin: AdminConfig{Email: "admin", Password: "123456"}} + created, reason, err := bootstrapAdminUser(context.Background(), db, cfg) + if err != nil || created || reason != tt.reason { + t.Fatalf("bootstrapAdminUser() = (%v, %q, %v), want (false, %q, nil)", created, reason, err, tt.reason) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected database interaction: %v", err) + } + }) + } +} + +func TestBootstrapAdminUserRejectsWeakPasswordWithoutInsert(t *testing.T) { + t.Parallel() + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer func() { _ = db.Close() }() + expectAdminBootstrapCounts(mock, 0, 0) + + cfg := &SetupConfig{Admin: AdminConfig{Password: "123456"}} + created, _, err := bootstrapAdminUser(context.Background(), db, cfg) + if err == nil || created || !strings.Contains(err.Error(), "invalid admin password") { + t.Fatalf("bootstrapAdminUser() = (%v, %v), want invalid admin password error", created, err) + } + // 未设置 INSERT 期望:若发生插入,sqlmock 会返回非预期调用错误,上面的错误断言即失败。 + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected database interaction: %v", err) + } +} + +func TestBootstrapAdminUserCreatesAdminWithGeneratedCredentials(t *testing.T) { + t.Parallel() + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer func() { _ = db.Close() }() + expectAdminBootstrapCounts(mock, 0, 0) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO users")). + WithArgs( + sqlmock.AnyArg(), sqlmock.AnyArg(), service.RoleAdmin, sqlmock.AnyArg(), + sqlmock.AnyArg(), service.StatusActive, sqlmock.AnyArg(), sqlmock.AnyArg(), + ). + WillReturnResult(sqlmock.NewResult(1, 1)) + + cfg := &SetupConfig{} + created, reason, err := bootstrapAdminUser(context.Background(), db, cfg) + if err != nil || !created || reason != adminBootstrapReasonEmptyDatabase { + t.Fatalf("bootstrapAdminUser() = (%v, %q, %v), want (true, %q, nil)", created, reason, err, adminBootstrapReasonEmptyDatabase) + } + if !regexp.MustCompile(`^admin-[0-9a-f]{12}@sub2api\.local$`).MatchString(cfg.Admin.Email) { + t.Fatalf("admin email = %q, want generated admin-<12 hex>@sub2api.local", cfg.Admin.Email) + } + if len(cfg.Admin.Password) != 32 { + t.Fatalf("admin password length = %d, want generated 32", len(cfg.Admin.Password)) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("database expectations not met: %v", err) + } +} + +func TestPrepareAdminCredentialsAcceptsMinimumLengthPassword(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Password: "12345678"} + emailGenerated, passwordGenerated, err := prepareAdminCredentials(&admin) + if err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + if !emailGenerated || passwordGenerated { + t.Fatalf("generated flags = (%v, %v), want (true, false)", emailGenerated, passwordGenerated) + } + if admin.Password != "12345678" { + t.Fatalf("password = %q, want unchanged", admin.Password) + } +} diff --git a/deploy/.env.example b/deploy/.env.example index 1299e45c56..fa10e83e79 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -206,10 +206,12 @@ REDIS_ENABLE_TLS=false # ----------------------------------------------------------------------------- # Admin Account # ----------------------------------------------------------------------------- -# Email for the admin account -ADMIN_EMAIL=admin@sub2api.local +# Email for the admin account (used as the login username) +# Leave empty to auto-generate a random one (will be shown in logs on first run). +# Avoid well-known values such as admin@sub2api.local: they are brute-force targets. +ADMIN_EMAIL= -# Password for admin account +# Password for admin account (8-72 bytes) # Leave empty to auto-generate (will be shown in logs on first run) ADMIN_PASSWORD= diff --git a/deploy/APPLE_CONTAINER.md b/deploy/APPLE_CONTAINER.md index 9e44f24408..f0b86b33d9 100644 --- a/deploy/APPLE_CONTAINER.md +++ b/deploy/APPLE_CONTAINER.md @@ -41,7 +41,7 @@ nano .env ./apple-container.sh status ``` -Open `http://localhost:8080`. If `ADMIN_PASSWORD` is empty, retrieve the generated password with: +Open `http://localhost:8080`. If `ADMIN_EMAIL` / `ADMIN_PASSWORD` are empty, retrieve the generated admin email (login username) and password with: ```bash ./apple-container.sh logs app diff --git a/deploy/README.md b/deploy/README.md index f38257e397..b71334d0a9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -79,8 +79,8 @@ docker compose -f docker-compose.local.yml up -d # View logs docker compose -f docker-compose.local.yml logs -f sub2api -# If admin password was auto-generated, find it in logs: -docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" +# If admin email/password were auto-generated, find them in logs: +docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin" # Access Web UI # http://localhost:8080 @@ -112,7 +112,7 @@ mkdir -p data postgres_data redis_data # Start all services using local directory version docker compose -f docker-compose.local.yml up -d -# View logs (check for auto-generated admin password) +# View logs (check for auto-generated admin email and password) docker compose -f docker-compose.local.yml logs -f sub2api # Access Web UI @@ -136,14 +136,14 @@ When using Docker Compose with `AUTO_SETUP=true`: - Connects to PostgreSQL and Redis - Applies database migrations (SQL files in `backend/migrations/*.sql`) and records them in `schema_migrations` - Generates JWT secret (if not provided) - - Creates admin account (password auto-generated if not provided) + - Creates admin account (email and password auto-generated if not provided; a provided password must be 8-72 bytes) - Writes config.yaml 2. No manual Setup Wizard needed - just configure `.env` and start -3. If `ADMIN_PASSWORD` is not set, check logs for the generated password: +3. If `ADMIN_EMAIL` / `ADMIN_PASSWORD` are not set, check logs for the generated admin email (login username) and password: ```bash - docker compose logs sub2api | grep "admin password" + docker compose logs sub2api | grep "Generated admin" ``` ### Startup and Database Recovery @@ -257,8 +257,8 @@ docker compose down -v | `JWT_SECRET` | **Recommended** | *(auto-generated)* | JWT secret (fixed for persistent sessions) | | `TOTP_ENCRYPTION_KEY` | **Recommended** | *(auto-generated)* | TOTP encryption key (fixed for persistent 2FA) | | `SERVER_PORT` | No | `8080` | Server port | -| `ADMIN_EMAIL` | No | `admin@sub2api.local` | Admin email | -| `ADMIN_PASSWORD` | No | *(auto-generated)* | Admin password | +| `ADMIN_EMAIL` | No | *(auto-generated)* | Admin email (login username) | +| `ADMIN_PASSWORD` | No | *(auto-generated)* | Admin password (8-72 bytes) | | `TZ` | No | `Asia/Shanghai` | Timezone | | `UPDATE_GITHUB_TOKEN` | No | *(empty)* | Token for `api.github.com` release checks only; asset downloads remain anonymous. | | `GEMINI_OAUTH_CLIENT_ID` | No | *(builtin)* | Google OAuth client ID (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. | diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 80d9db1785..45789f48ab 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -43,7 +43,7 @@ services: - REDIS_USERNAME=${REDIS_USERNAME:-} - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_DB=${REDIS_DB:-0} - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} - JWT_SECRET=${JWT_SECRET:-} - SETUP_MIGRATION_TIMEOUT_SECONDS=${SETUP_MIGRATION_TIMEOUT_SECONDS:-0} diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 672463c4d7..32bc439f4d 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -88,7 +88,7 @@ services: # ======================================================================= # Admin Account (auto-created on first run) # ======================================================================= - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # ======================================================================= diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index abbd8c23f7..fe9619df15 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -74,7 +74,7 @@ services: # ======================================================================= # Admin Account (auto-created on first run) # ======================================================================= - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # ======================================================================= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3308df0ffe..84c98643ea 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -85,7 +85,7 @@ services: # ======================================================================= # Admin Account (auto-created on first run) # ======================================================================= - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # ======================================================================= diff --git a/deploy/docker-deploy.sh b/deploy/docker-deploy.sh index a07f4f417a..97e8e8e04c 100644 --- a/deploy/docker-deploy.sh +++ b/deploy/docker-deploy.sh @@ -5,6 +5,7 @@ # This script prepares deployment files for Sub2API: # - Downloads docker-compose.local.yml and .env.example # - Generates secure secrets (JWT_SECRET, TOTP_ENCRYPTION_KEY, POSTGRES_PASSWORD) +# - Generates a random admin login email (ADMIN_EMAIL) # - Creates necessary data directories # # After running this script, you can start services with: @@ -45,6 +46,14 @@ generate_secret() { openssl rand -hex 32 } +# Generate a random admin login email so new installs never use a guessable default +generate_admin_email() { + local suffix + suffix=$(openssl rand -hex 6) || return 1 + [ -n "$suffix" ] || return 1 + echo "admin-${suffix}@sub2api.local" +} + # Check if command exists command_exists() { command -v "$1" >/dev/null 2>&1 @@ -104,6 +113,7 @@ main() { JWT_SECRET=$(generate_secret) TOTP_ENCRYPTION_KEY=$(generate_secret) POSTGRES_PASSWORD=$(generate_secret) + ADMIN_EMAIL=$(generate_admin_email) # Create .env from .env.example cp .env.example .env @@ -114,11 +124,13 @@ main() { sed -i "s/^JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" .env sed -i "s/^TOTP_ENCRYPTION_KEY=.*/TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY}/" .env sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${POSTGRES_PASSWORD}/" .env + sed -i "s/^ADMIN_EMAIL=.*/ADMIN_EMAIL=${ADMIN_EMAIL}/" .env else # BSD sed (macOS) sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" .env sed -i '' "s/^TOTP_ENCRYPTION_KEY=.*/TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY}/" .env sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${POSTGRES_PASSWORD}/" .env + sed -i '' "s/^ADMIN_EMAIL=.*/ADMIN_EMAIL=${ADMIN_EMAIL}/" .env fi # Create data directories @@ -139,6 +151,7 @@ main() { echo " POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}" echo " JWT_SECRET: ${JWT_SECRET}" echo " TOTP_ENCRYPTION_KEY: ${TOTP_ENCRYPTION_KEY}" + echo " ADMIN_EMAIL: ${ADMIN_EMAIL} (admin login username)" echo "" print_warning "These credentials have been saved to .env file." print_warning "Please keep them secure and do not share publicly!" diff --git a/frontend/package.json b/frontend/package.json index d15078ce6b..e714a702f8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,12 +29,12 @@ "marked": "^17.0.1", "pinia": "^2.1.7", "qrcode": "^1.5.4", - "vue": "^3.5.42", + "vue": "^3.5.43", "vue-chartjs": "^5.3.0", "vue-draggable-plus": "^0.6.1", "vue-i18n": "^9.14.5", "vue-router": "^4.2.5", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" + "xlsx": "^0.18.5" }, "devDependencies": { "@intlify/message-compiler": "9.14.5", @@ -65,7 +65,8 @@ "js-cookie": "3.0.7", "form-data@<4.0.6": ">=4.0.6", "postcss@<8.5.18": ">=8.5.18", - "dompurify@<3.4.14": ">=3.4.14" + "dompurify@<3.4.14": ">=3.4.14", + "source-map-js@<1.2.2": ">=1.2.2" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 471eeeb22c..1e36493ef0 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -9,6 +9,7 @@ overrides: form-data@<4.0.6: '>=4.0.6' postcss@<8.5.18: '>=8.5.18' dompurify@<3.4.14: '>=3.4.14' + source-map-js@<1.2.2: '>=1.2.2' importers: @@ -25,10 +26,10 @@ importers: version: 9.0.1 '@tanstack/vue-virtual': specifier: ^3.13.23 - version: 3.13.23(vue@3.5.42(typescript@5.6.3)) + version: 3.13.23(vue@3.5.43(typescript@5.6.3)) '@vueuse/core': specifier: ^10.7.0 - version: 10.11.1(vue@3.5.42(typescript@5.6.3)) + version: 10.11.1(vue@3.5.43(typescript@5.6.3)) axios: specifier: ^1.20.0 version: 1.20.0 @@ -49,28 +50,28 @@ importers: version: 17.0.1 pinia: specifier: ^2.1.7 - version: 2.3.1(typescript@5.6.3)(vue@3.5.42(typescript@5.6.3)) + version: 2.3.1(typescript@5.6.3)(vue@3.5.43(typescript@5.6.3)) qrcode: specifier: ^1.5.4 version: 1.5.4 vue: - specifier: ^3.5.42 - version: 3.5.42(typescript@5.6.3) + specifier: ^3.5.43 + version: 3.5.43(typescript@5.6.3) vue-chartjs: specifier: ^5.3.0 - version: 5.3.3(chart.js@4.5.1)(vue@3.5.42(typescript@5.6.3)) + version: 5.3.3(chart.js@4.5.1)(vue@3.5.43(typescript@5.6.3)) vue-draggable-plus: specifier: ^0.6.1 version: 0.6.1(@types/sortablejs@1.15.9) vue-i18n: specifier: ^9.14.5 - version: 9.14.5(vue@3.5.42(typescript@5.6.3)) + version: 9.14.5(vue@3.5.43(typescript@5.6.3)) vue-router: specifier: ^4.2.5 - version: 4.6.4(vue@3.5.42(typescript@5.6.3)) + version: 4.6.4(vue@3.5.43(typescript@5.6.3)) xlsx: - specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz - version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + specifier: ^0.18.5 + version: 0.18.5 devDependencies: '@intlify/message-compiler': specifier: 9.14.5 @@ -98,7 +99,7 @@ importers: version: 7.18.0(eslint@8.57.1)(typescript@5.6.3) '@vitejs/plugin-vue': specifier: ^5.2.3 - version: 5.2.4(vite@5.4.21(@types/node@20.19.27))(vue@3.5.42(typescript@5.6.3)) + version: 5.2.4(vite@5.4.21(@types/node@20.19.27))(vue@3.5.43(typescript@5.6.3)) '@vitest/coverage-v8': specifier: ^2.1.9 version: 2.1.9(vitest@2.1.9(@types/node@20.19.27)(jsdom@24.1.3)) @@ -1706,17 +1707,23 @@ packages: '@volar/typescript@2.4.15': resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} - '@vue/compiler-core@3.5.42': - resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==} + '@vue/compiler-core@3.5.26': + resolution: {integrity: sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==} - '@vue/compiler-dom@3.5.42': - resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==} + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} - '@vue/compiler-sfc@3.5.42': - resolution: {integrity: sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==} + '@vue/compiler-dom@3.5.26': + resolution: {integrity: sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==} - '@vue/compiler-ssr@3.5.42': - resolution: {integrity: sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==} + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} + + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} '@vue/compiler-vue2@2.7.16': resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} @@ -1732,20 +1739,23 @@ packages: typescript: optional: true - '@vue/reactivity@3.5.42': - resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==} + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} - '@vue/runtime-core@3.5.42': - resolution: {integrity: sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==} + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} - '@vue/runtime-dom@3.5.42': - resolution: {integrity: sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==} + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} - '@vue/server-renderer@3.5.42': - resolution: {integrity: sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==} + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} - '@vue/shared@3.5.42': - resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} + '@vue/shared@3.5.26': + resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==} + + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} '@vue/test-utils@2.4.6': resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==} @@ -1778,6 +1788,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -1934,6 +1948,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -1995,6 +2013,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -2056,6 +2078,11 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2336,6 +2363,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.0: + resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -2550,6 +2581,10 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -3298,6 +3333,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3526,6 +3566,10 @@ packages: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3945,6 +3989,10 @@ packages: resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} engines: {node: '>=0.10.0'} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4380,8 +4428,8 @@ packages: peerDependencies: typescript: '>=5.0.0' - vue@3.5.42: - resolution: {integrity: sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==} + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} peerDependencies: typescript: '*' peerDependenciesMeta: @@ -4425,10 +4473,18 @@ packages: engines: {node: '>=8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -4456,9 +4512,8 @@ packages: utf-8-validate: optional: true - xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: - resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} - version: 0.20.3 + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} engines: {node: '>=0.8'} hasBin: true @@ -5040,7 +5095,7 @@ snapshots: '@lobehub/icons@4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@lobehub/ui': 4.9.2(@lobehub/fluent-emoji@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@lobehub/icons@4.0.2)(@types/mdast@4.0.4)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(micromark-util-types@2.0.2)(micromark@4.0.2)(motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.42(typescript@5.6.3)) + '@lobehub/ui': 4.9.2(@lobehub/fluent-emoji@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@lobehub/icons@4.0.2)(@types/mdast@4.0.4)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(micromark-util-types@2.0.2)(micromark@4.0.2)(motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.43(typescript@5.6.3)) antd: 6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) antd-style: 4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) lucide-react: 0.469.0(react@19.2.3) @@ -5051,7 +5106,7 @@ snapshots: - '@types/react' - supports-color - '@lobehub/ui@4.9.2(@lobehub/fluent-emoji@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@lobehub/icons@4.0.2)(@types/mdast@4.0.4)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(micromark-util-types@2.0.2)(micromark@4.0.2)(motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.42(typescript@5.6.3))': + '@lobehub/ui@4.9.2(@lobehub/fluent-emoji@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@lobehub/icons@4.0.2)(@types/mdast@4.0.4)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(micromark-util-types@2.0.2)(micromark@4.0.2)(motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.43(typescript@5.6.3))': dependencies: '@ant-design/cssinjs': 2.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@base-ui/react': 1.3.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -5116,7 +5171,7 @@ snapshots: remark-github: 12.0.0 remark-math: 6.0.0 shiki: 3.23.0 - shiki-stream: 0.1.4(react@19.2.3)(vue@3.5.42(typescript@5.6.3)) + shiki-stream: 0.1.4(react@19.2.3)(vue@3.5.43(typescript@5.6.3)) swr: 2.4.1(react@19.2.3) ts-md5: 2.0.1 unified: 11.0.5 @@ -5868,10 +5923,10 @@ snapshots: '@tanstack/virtual-core@3.13.23': {} - '@tanstack/vue-virtual@3.13.23(vue@3.5.42(typescript@5.6.3))': + '@tanstack/vue-virtual@3.13.23(vue@3.5.43(typescript@5.6.3))': dependencies: '@tanstack/virtual-core': 3.13.23 - vue: 3.5.42(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) '@types/d3-array@3.2.2': {} @@ -6143,10 +6198,10 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.3 - '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.27))(vue@3.5.42(typescript@5.6.3))': + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.27))(vue@3.5.43(typescript@5.6.3))': dependencies: vite: 5.4.21(@types/node@20.19.27) - vue: 3.5.42(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@20.19.27)(jsdom@24.1.3))': dependencies: @@ -6218,35 +6273,48 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 - '@vue/compiler-core@3.5.42': + '@vue/compiler-core@3.5.26': + dependencies: + '@babel/parser': 7.28.5 + '@vue/shared': 3.5.26 + entities: 7.0.0 + estree-walker: 2.0.2 + source-map-js: 1.2.2 + + '@vue/compiler-core@3.5.43': dependencies: '@babel/parser': 7.29.9 - '@vue/shared': 3.5.42 + '@vue/shared': 3.5.43 entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.2 - '@vue/compiler-dom@3.5.42': + '@vue/compiler-dom@3.5.26': dependencies: - '@vue/compiler-core': 3.5.42 - '@vue/shared': 3.5.42 + '@vue/compiler-core': 3.5.26 + '@vue/shared': 3.5.26 - '@vue/compiler-sfc@3.5.42': + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': dependencies: '@babel/parser': 7.29.9 - '@vue/compiler-core': 3.5.42 - '@vue/compiler-dom': 3.5.42 - '@vue/compiler-ssr': 3.5.42 - '@vue/shared': 3.5.42 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.23 + postcss: 8.5.29 source-map-js: 1.2.2 - '@vue/compiler-ssr@3.5.42': + '@vue/compiler-ssr@3.5.43': dependencies: - '@vue/compiler-dom': 3.5.42 - '@vue/shared': 3.5.42 + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 '@vue/compiler-vue2@2.7.16': dependencies: @@ -6258,9 +6326,9 @@ snapshots: '@vue/language-core@2.2.12(typescript@5.6.3)': dependencies: '@volar/language-core': 2.4.15 - '@vue/compiler-dom': 3.5.42 + '@vue/compiler-dom': 3.5.26 '@vue/compiler-vue2': 2.7.16 - '@vue/shared': 3.5.42 + '@vue/shared': 3.5.26 alien-signals: 1.0.13 minimatch: 9.0.5 muggle-string: 0.4.1 @@ -6268,50 +6336,52 @@ snapshots: optionalDependencies: typescript: 5.6.3 - '@vue/reactivity@3.5.42': + '@vue/reactivity@3.5.43': dependencies: - '@vue/shared': 3.5.42 + '@vue/shared': 3.5.43 - '@vue/runtime-core@3.5.42': + '@vue/runtime-core@3.5.43': dependencies: - '@vue/reactivity': 3.5.42 - '@vue/shared': 3.5.42 + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 - '@vue/runtime-dom@3.5.42': + '@vue/runtime-dom@3.5.43': dependencies: - '@vue/reactivity': 3.5.42 - '@vue/runtime-core': 3.5.42 - '@vue/shared': 3.5.42 + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 csstype: 3.2.3 - '@vue/server-renderer@3.5.42': + '@vue/server-renderer@3.5.43': dependencies: - '@vue/compiler-ssr': 3.5.42 - '@vue/runtime-dom': 3.5.42 - '@vue/shared': 3.5.42 + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 - '@vue/shared@3.5.42': {} + '@vue/shared@3.5.26': {} + + '@vue/shared@3.5.43': {} '@vue/test-utils@2.4.6': dependencies: js-beautify: 1.15.4 vue-component-type-helpers: 2.2.12 - '@vueuse/core@10.11.1(vue@3.5.42(typescript@5.6.3))': + '@vueuse/core@10.11.1(vue@3.5.43(typescript@5.6.3))': dependencies: '@types/web-bluetooth': 0.0.20 '@vueuse/metadata': 10.11.1 - '@vueuse/shared': 10.11.1(vue@3.5.42(typescript@5.6.3)) - vue-demi: 0.14.10(vue@3.5.42(typescript@5.6.3)) + '@vueuse/shared': 10.11.1(vue@3.5.43(typescript@5.6.3)) + vue-demi: 0.14.10(vue@3.5.43(typescript@5.6.3)) transitivePeerDependencies: - '@vue/composition-api' - vue '@vueuse/metadata@10.11.1': {} - '@vueuse/shared@10.11.1(vue@3.5.42(typescript@5.6.3))': + '@vueuse/shared@10.11.1(vue@3.5.43(typescript@5.6.3))': dependencies: - vue-demi: 0.14.10(vue@3.5.42(typescript@5.6.3)) + vue-demi: 0.14.10(vue@3.5.43(typescript@5.6.3)) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -6330,6 +6400,8 @@ snapshots: acorn@8.16.0: {} + adler-32@1.3.1: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -6542,6 +6614,11 @@ snapshots: ccount@2.0.1: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -6614,6 +6691,8 @@ snapshots: clsx@2.1.1: {} + codepage@1.15.0: {} + collapse-white-space@2.1.0: {} color-convert@2.0.1: @@ -6667,6 +6746,8 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 + crc-32@1.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6952,6 +7033,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.0: {} + entities@7.0.1: {} error-ex@1.3.4: @@ -7228,6 +7311,8 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + frac@1.1.2: {} + fraction.js@5.3.4: {} framer-motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -8383,6 +8468,8 @@ snapshots: nanoid@3.3.18: {} + nanoid@3.3.20: {} + natural-compare@1.4.0: {} node-releases@2.0.27: {} @@ -8515,11 +8602,11 @@ snapshots: pify@2.3.0: {} - pinia@2.3.1(typescript@5.6.3)(vue@3.5.42(typescript@5.6.3)): + pinia@2.3.1(typescript@5.6.3)(vue@3.5.43(typescript@5.6.3)): dependencies: '@vue/devtools-api': 6.6.4 - vue: 3.5.42(typescript@5.6.3) - vue-demi: 0.14.10(vue@3.5.42(typescript@5.6.3)) + vue: 3.5.43(typescript@5.6.3) + vue-demi: 0.14.10(vue@3.5.43(typescript@5.6.3)) optionalDependencies: typescript: 5.6.3 transitivePeerDependencies: @@ -8583,6 +8670,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + prelude-ls@1.2.1: {} prop-types@15.8.1: @@ -9063,12 +9156,12 @@ snapshots: shebang-regex@3.0.0: {} - shiki-stream@0.1.4(react@19.2.3)(vue@3.5.42(typescript@5.6.3)): + shiki-stream@0.1.4(react@19.2.3)(vue@3.5.43(typescript@5.6.3)): dependencies: '@shikijs/core': 3.23.0 optionalDependencies: react: 19.2.3 - vue: 3.5.42(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) shiki@3.23.0: dependencies: @@ -9101,6 +9194,10 @@ snapshots: dependencies: extend-shallow: 3.0.2 + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stackback@0.0.2: {} std-env@3.10.0: {} @@ -9483,16 +9580,16 @@ snapshots: vscode-uri@3.1.0: {} - vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.42(typescript@5.6.3)): + vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.43(typescript@5.6.3)): dependencies: chart.js: 4.5.1 - vue: 3.5.42(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) vue-component-type-helpers@2.2.12: {} - vue-demi@0.14.10(vue@3.5.42(typescript@5.6.3)): + vue-demi@0.14.10(vue@3.5.43(typescript@5.6.3)): dependencies: - vue: 3.5.42(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) vue-draggable-plus@0.6.1(@types/sortablejs@1.15.9): dependencies: @@ -9511,17 +9608,17 @@ snapshots: transitivePeerDependencies: - supports-color - vue-i18n@9.14.5(vue@3.5.42(typescript@5.6.3)): + vue-i18n@9.14.5(vue@3.5.43(typescript@5.6.3)): dependencies: '@intlify/core-base': 9.14.5 '@intlify/shared': 9.14.5 '@vue/devtools-api': 6.6.4 - vue: 3.5.42(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) - vue-router@4.6.4(vue@3.5.42(typescript@5.6.3)): + vue-router@4.6.4(vue@3.5.43(typescript@5.6.3)): dependencies: '@vue/devtools-api': 6.6.4 - vue: 3.5.42(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) vue-tsc@2.2.12(typescript@5.6.3): dependencies: @@ -9529,13 +9626,13 @@ snapshots: '@vue/language-core': 2.2.12(typescript@5.6.3) typescript: 5.6.3 - vue@3.5.42(typescript@5.6.3): + vue@3.5.43(typescript@5.6.3): dependencies: - '@vue/compiler-dom': 3.5.42 - '@vue/compiler-sfc': 3.5.42 - '@vue/runtime-dom': 3.5.42 - '@vue/server-renderer': 3.5.42 - '@vue/shared': 3.5.42 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 optionalDependencies: typescript: 5.6.3 @@ -9569,8 +9666,12 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wmf@1.0.2: {} + word-wrap@1.2.5: {} + word@0.3.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -9593,7 +9694,15 @@ snapshots: ws@8.19.0: {} - xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {} + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 xml-name-validator@4.0.0: {} diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 1de1b7758f..4b2c0585f8 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -1045,7 +1045,7 @@ ${codexModelCatalogMode.value === 'remote' ? `model_catalog_url = "${escapeTomlB ${generateCodexProviderAuthConfig(apiKey)} [features] -goals = true` +${codexModelCatalogMode.value === 'remote' ? 'api_key_model_discovery = true\n' : ''}goals = true` return buildOpenAICodexFileConfigs(configDir, configContent, apiKey) } @@ -1288,8 +1288,7 @@ requires_openai_auth = false # Grok/Sub2API path is HTTP/SSE; disable WS (Codex may otherwise try WebSocket first) supports_websockets = false -# Optional: -# [features] +${codexModelCatalogMode.value === 'remote' ? '[features]\napi_key_model_discovery = true\n\n# Optional:' : '# Optional:\n# [features]'} # goals = true` return [ @@ -1355,7 +1354,7 @@ base_url = "${baseUrl}" ${codexModelCatalogMode.value === 'remote' ? `model_catalog_url = "${escapeTomlBasicString(buildCodexModelCatalogUrl(baseUrl))}"\n` : ''}env_key = "SUB2API_API_KEY" wire_api = "responses" requires_openai_auth = false -supports_websockets = false` +supports_websockets = false${codexModelCatalogMode.value === 'remote' ? '\n\n[features]\napi_key_model_discovery = true' : ''}` return [ { path: isWindows ? 'PowerShell' : 'Terminal', content: envContent }, @@ -1393,7 +1392,7 @@ supports_websockets = true ${generateCodexProviderAuthConfig(apiKey)} [features] -responses_websockets_v2 = true +${codexModelCatalogMode.value === 'remote' ? 'api_key_model_discovery = true\n' : ''}responses_websockets_v2 = true goals = true` return buildOpenAICodexFileConfigs(configDir, configContent, apiKey)