mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f9d460dc6 | ||
|
|
6e315616ed | ||
|
|
d97ccc952f | ||
|
|
33d9e72f90 | ||
|
|
d1aac6b989 | ||
|
|
bbba01dae9 | ||
|
|
b8dece9000 |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-4
@@ -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"
|
||||
```
|
||||
|
||||
#### 升级
|
||||
|
||||
+6
-4
@@ -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"
|
||||
```
|
||||
|
||||
#### アップグレード
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.2.12
|
||||
0.2.13
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 = "/"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 <a@b.com>") 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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 <owner@example.com>", "<owner@example.com>"} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -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=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-8
@@ -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. |
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
# =======================================================================
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
# =======================================================================
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
# =======================================================================
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"marked": "^17.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.4.0",
|
||||
"vue": "^3.5.43",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-draggable-plus": "^0.6.1",
|
||||
"vue-i18n": "^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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+163
-87
@@ -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.26(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.26(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,25 +50,25 @@ importers:
|
||||
version: 17.0.1
|
||||
pinia:
|
||||
specifier: ^2.1.7
|
||||
version: 2.3.1(typescript@5.6.3)(vue@3.5.26(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.4.0
|
||||
version: 3.5.26(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.26(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.26(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.26(typescript@5.6.3))
|
||||
version: 4.6.4(vue@3.5.43(typescript@5.6.3))
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.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.26(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))
|
||||
@@ -216,15 +217,28 @@ packages:
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.28.5':
|
||||
resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/parser@7.29.9':
|
||||
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/runtime@7.28.4':
|
||||
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -245,6 +259,10 @@ packages:
|
||||
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.8':
|
||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@base-ui/react@1.3.0':
|
||||
resolution: {integrity: sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -1692,14 +1710,20 @@ packages:
|
||||
'@vue/compiler-core@3.5.26':
|
||||
resolution: {integrity: sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==}
|
||||
|
||||
'@vue/compiler-core@3.5.43':
|
||||
resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==}
|
||||
|
||||
'@vue/compiler-dom@3.5.26':
|
||||
resolution: {integrity: sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==}
|
||||
|
||||
'@vue/compiler-sfc@3.5.26':
|
||||
resolution: {integrity: sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==}
|
||||
'@vue/compiler-dom@3.5.43':
|
||||
resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==}
|
||||
|
||||
'@vue/compiler-ssr@3.5.26':
|
||||
resolution: {integrity: sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==}
|
||||
'@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==}
|
||||
@@ -1715,23 +1739,24 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@vue/reactivity@3.5.26':
|
||||
resolution: {integrity: sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==}
|
||||
'@vue/reactivity@3.5.43':
|
||||
resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==}
|
||||
|
||||
'@vue/runtime-core@3.5.26':
|
||||
resolution: {integrity: sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==}
|
||||
'@vue/runtime-core@3.5.43':
|
||||
resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==}
|
||||
|
||||
'@vue/runtime-dom@3.5.26':
|
||||
resolution: {integrity: sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==}
|
||||
'@vue/runtime-dom@3.5.43':
|
||||
resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==}
|
||||
|
||||
'@vue/server-renderer@3.5.26':
|
||||
resolution: {integrity: sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==}
|
||||
peerDependencies:
|
||||
vue: 3.5.26
|
||||
'@vue/server-renderer@3.5.43':
|
||||
resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==}
|
||||
|
||||
'@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==}
|
||||
|
||||
@@ -2342,6 +2367,10 @@ packages:
|
||||
resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@7.0.1:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
error-ex@1.3.4:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
@@ -3304,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==}
|
||||
|
||||
@@ -3532,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'}
|
||||
@@ -3928,8 +3966,8 @@ packages:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
source-map-js@1.2.2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map@0.5.7:
|
||||
@@ -4390,8 +4428,8 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
|
||||
vue@3.5.26:
|
||||
resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==}
|
||||
vue@3.5.43:
|
||||
resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
@@ -4620,12 +4658,20 @@ snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/parser@7.28.5':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.5
|
||||
|
||||
'@babel/parser@7.29.9':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/runtime@7.28.4': {}
|
||||
|
||||
'@babel/runtime@7.29.2': {}
|
||||
@@ -4653,6 +4699,11 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@babel/types@7.29.8':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@base-ui/react@1.3.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
@@ -4988,7 +5039,7 @@ snapshots:
|
||||
'@intlify/message-compiler@9.14.5':
|
||||
dependencies:
|
||||
'@intlify/shared': 9.14.5
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
'@intlify/shared@9.14.5': {}
|
||||
|
||||
@@ -5044,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.26(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)
|
||||
@@ -5055,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.26(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)
|
||||
@@ -5120,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.26(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
|
||||
@@ -5872,10 +5923,10 @@ snapshots:
|
||||
|
||||
'@tanstack/virtual-core@3.13.23': {}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.23(vue@3.5.26(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.26(typescript@5.6.3)
|
||||
vue: 3.5.43(typescript@5.6.3)
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
@@ -6147,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.26(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.26(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:
|
||||
@@ -6228,29 +6279,42 @@ snapshots:
|
||||
'@vue/shared': 3.5.26
|
||||
entities: 7.0.0
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
'@vue/compiler-core@3.5.43':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.9
|
||||
'@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.26':
|
||||
dependencies:
|
||||
'@vue/compiler-core': 3.5.26
|
||||
'@vue/shared': 3.5.26
|
||||
|
||||
'@vue/compiler-sfc@3.5.26':
|
||||
'@vue/compiler-dom@3.5.43':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.5
|
||||
'@vue/compiler-core': 3.5.26
|
||||
'@vue/compiler-dom': 3.5.26
|
||||
'@vue/compiler-ssr': 3.5.26
|
||||
'@vue/shared': 3.5.26
|
||||
'@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.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
|
||||
source-map-js: 1.2.1
|
||||
postcss: 8.5.29
|
||||
source-map-js: 1.2.2
|
||||
|
||||
'@vue/compiler-ssr@3.5.26':
|
||||
'@vue/compiler-ssr@3.5.43':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.26
|
||||
'@vue/shared': 3.5.26
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/compiler-vue2@2.7.16':
|
||||
dependencies:
|
||||
@@ -6272,50 +6336,52 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.6.3
|
||||
|
||||
'@vue/reactivity@3.5.26':
|
||||
'@vue/reactivity@3.5.43':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.26
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/runtime-core@3.5.26':
|
||||
'@vue/runtime-core@3.5.43':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.26
|
||||
'@vue/shared': 3.5.26
|
||||
'@vue/reactivity': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/runtime-dom@3.5.26':
|
||||
'@vue/runtime-dom@3.5.43':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.26
|
||||
'@vue/runtime-core': 3.5.26
|
||||
'@vue/shared': 3.5.26
|
||||
'@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.26(vue@3.5.26(typescript@5.6.3))':
|
||||
'@vue/server-renderer@3.5.43':
|
||||
dependencies:
|
||||
'@vue/compiler-ssr': 3.5.26
|
||||
'@vue/shared': 3.5.26
|
||||
vue: 3.5.26(typescript@5.6.3)
|
||||
'@vue/compiler-ssr': 3.5.43
|
||||
'@vue/runtime-dom': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@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.26(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.26(typescript@5.6.3))
|
||||
vue-demi: 0.14.10(vue@3.5.26(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.26(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.26(typescript@5.6.3))
|
||||
vue-demi: 0.14.10(vue@3.5.43(typescript@5.6.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
@@ -6969,6 +7035,8 @@ snapshots:
|
||||
|
||||
entities@7.0.0: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
@@ -7812,7 +7880,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/types': 7.28.5
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
@@ -8400,6 +8468,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.18: {}
|
||||
|
||||
nanoid@3.3.20: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
@@ -8532,11 +8602,11 @@ snapshots:
|
||||
|
||||
pify@2.3.0: {}
|
||||
|
||||
pinia@2.3.1(typescript@5.6.3)(vue@3.5.26(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.26(typescript@5.6.3)
|
||||
vue-demi: 0.14.10(vue@3.5.26(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:
|
||||
@@ -8598,7 +8668,13 @@ snapshots:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.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: {}
|
||||
|
||||
@@ -9080,12 +9156,12 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shiki-stream@0.1.4(react@19.2.3)(vue@3.5.26(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.26(typescript@5.6.3)
|
||||
vue: 3.5.43(typescript@5.6.3)
|
||||
|
||||
shiki@3.23.0:
|
||||
dependencies:
|
||||
@@ -9104,7 +9180,7 @@ snapshots:
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
source-map-js@1.2.2: {}
|
||||
|
||||
source-map@0.5.7: {}
|
||||
|
||||
@@ -9504,16 +9580,16 @@ snapshots:
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.26(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.26(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.26(typescript@5.6.3)):
|
||||
vue-demi@0.14.10(vue@3.5.43(typescript@5.6.3)):
|
||||
dependencies:
|
||||
vue: 3.5.26(typescript@5.6.3)
|
||||
vue: 3.5.43(typescript@5.6.3)
|
||||
|
||||
vue-draggable-plus@0.6.1(@types/sortablejs@1.15.9):
|
||||
dependencies:
|
||||
@@ -9532,17 +9608,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vue-i18n@9.14.5(vue@3.5.26(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.26(typescript@5.6.3)
|
||||
vue: 3.5.43(typescript@5.6.3)
|
||||
|
||||
vue-router@4.6.4(vue@3.5.26(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.26(typescript@5.6.3)
|
||||
vue: 3.5.43(typescript@5.6.3)
|
||||
|
||||
vue-tsc@2.2.12(typescript@5.6.3):
|
||||
dependencies:
|
||||
@@ -9550,13 +9626,13 @@ snapshots:
|
||||
'@vue/language-core': 2.2.12(typescript@5.6.3)
|
||||
typescript: 5.6.3
|
||||
|
||||
vue@3.5.26(typescript@5.6.3):
|
||||
vue@3.5.43(typescript@5.6.3):
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.26
|
||||
'@vue/compiler-sfc': 3.5.26
|
||||
'@vue/runtime-dom': 3.5.26
|
||||
'@vue/server-renderer': 3.5.26(vue@3.5.26(typescript@5.6.3))
|
||||
'@vue/shared': 3.5.26
|
||||
'@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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user