From dbd60d61be068de69df87fe4739ac572ebd913bc Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 18 Jul 2026 22:09:00 +0800 Subject: [PATCH 1/5] refactor(auth): replace dashboard sessions with stateless tokens --- .env.example | 5 +- README.en.md | 8 +- README.fr.md | 8 +- README.ja.md | 8 +- README.md | 8 +- README.zh_CN.md | 8 +- README.zh_TW.md | 8 +- THIRD-PARTY-LICENSES.md | 3 +- common/session_cookie.go | 40 +- common/sys_log.go | 5 +- common/url_validator_test.go | 26 + controller/auth_flow_test.go | 224 +++ controller/auth_session.go | 189 +++ controller/auth_session_test.go | 67 + controller/model_list_test.go | 6 +- controller/oauth.go | 154 +- controller/passkey.go | 265 +++- controller/passkey_test.go | 130 ++ controller/secure_verification.go | 173 +-- controller/telegram.go | 192 ++- controller/telegram_test.go | 141 +- controller/twofa.go | 124 +- controller/user.go | 255 ++-- controller/user_manage_test.go | 161 +++ controller/wechat.go | 12 +- docker-compose.dev.yml | 4 +- docker-compose.yml | 4 +- docs/authentication.md | 120 ++ docs/openapi/api.json | 1269 +++++------------ docs/openapi/relay.json | 982 ++----------- go.mod | 10 +- go.sum | 12 +- main.go | 15 +- middleware/auth.go | 264 ++-- middleware/auth_origin.go | 76 + middleware/auth_origin_test.go | 135 ++ middleware/auth_test.go | 94 ++ middleware/header_nav_test.go | 52 +- middleware/secure_verification.go | 153 +- middleware/turnstile-check.go | 19 +- model/auth_flow.go | 236 +++ model/auth_flow_test.go | 109 ++ model/errors.go | 1 + model/external_identity_claim.go | 103 ++ model/external_identity_claim_test.go | 91 ++ model/main.go | 18 + model/passkey.go | 119 +- model/subscription.go | 41 +- model/subscription_auth_test.go | 150 ++ model/task_cas_test.go | 6 + model/twofa.go | 166 ++- model/user.go | 131 +- model/user_auth_cache.go | 283 ++++ model/user_authentication_test.go | 205 ++- model/user_cache.go | 184 ++- model/user_cache_auth_version_test.go | 223 +++ model/user_session.go | 593 ++++++++ model/user_session_test.go | 209 +++ router/api-router.go | 53 +- service/auth_cleanup.go | 36 + service/auth_session.go | 400 ++++++ service/auth_session_test.go | 118 ++ service/auth_token.go | 216 +++ service/auth_token_test.go | 140 ++ service/passkey/service.go | 6 - service/passkey/session.go | 73 +- .../src/components/sign-out-dialog.tsx | 36 +- web/default/src/features/auth/api.test.ts | 120 ++ web/default/src/features/auth/api.ts | 84 +- web/default/src/features/auth/constants.ts | 4 +- .../features/auth/hooks/use-auth-redirect.ts | 51 +- .../features/auth/hooks/use-oauth-login.ts | 72 +- web/default/src/features/auth/index.ts | 10 +- .../auth/lib/oauth-bind-window.test.ts | 91 ++ .../features/auth/lib/oauth-bind-window.ts | 74 + web/default/src/features/auth/lib/storage.ts | 45 - .../features/auth/otp/components/otp-form.tsx | 34 +- web/default/src/features/auth/passkey/api.ts | 55 +- .../passkey/hooks/use-passkey-management.ts | 171 ++- .../src/features/auth/passkey/types.ts | 2 + .../features/auth/secure-verification/api.ts | 84 +- .../hooks/use-secure-verification.ts | 32 +- .../auth/secure-verification/types.ts | 14 + .../sign-in/components/user-auth-form.tsx | 37 +- .../auth/sign-up/components/sign-up-form.tsx | 35 +- web/default/src/features/auth/types.ts | 20 +- web/default/src/features/channels/api.ts | 9 +- .../dialogs/ollama-models-dialog.tsx | 5 +- .../drawers/channel-mutate-drawer.tsx | 49 +- .../playground/hooks/use-chat-handler.ts | 226 ++- .../hooks/use-stream-request.test.ts | 200 +++ .../playground/hooks/use-stream-request.ts | 268 ++-- web/default/src/features/profile/api.ts | 39 +- .../dialogs/delete-account-dialog.tsx | 12 +- .../dialogs/telegram-bind-dialog.tsx | 112 +- .../components/login-session-dialogs.tsx | 80 ++ .../profile/components/login-session-item.tsx | 77 + .../components/login-session-utils.test.ts | 56 + .../profile/components/login-session-utils.ts | 74 + .../components/login-sessions-card.tsx | 212 +++ .../profile/components/passkey-card.tsx | 2 + .../components/tabs/account-bindings-tab.tsx | 294 +++- web/default/src/features/profile/index.tsx | 2 + web/default/src/i18n/locales/en.json | 42 + web/default/src/i18n/locales/fr.json | 42 + web/default/src/i18n/locales/ja.json | 42 + web/default/src/i18n/locales/ru.json | 42 + web/default/src/i18n/locales/vi.json | 42 + web/default/src/i18n/locales/zh-TW.json | 42 + web/default/src/i18n/locales/zh.json | 42 + web/default/src/lib/api.ts | 211 +-- web/default/src/lib/auth-session-sync.ts | 119 ++ web/default/src/lib/auth-session.test.ts | 266 ++++ web/default/src/lib/auth-session.ts | 411 ++++++ web/default/src/lib/http-client.ts | 140 ++ web/default/src/lib/oauth.ts | 76 - web/default/src/lib/secure-verification.ts | 5 + web/default/src/main.tsx | 12 +- web/default/src/routes/(auth)/oauth.tsx | 18 +- web/default/src/routes/__root.tsx | 71 +- .../src/routes/_authenticated/route.tsx | 31 +- web/default/src/routes/oauth/$provider.tsx | 314 ++-- web/default/src/stores/auth-store.ts | 129 +- 123 files changed, 10297 insertions(+), 3927 deletions(-) create mode 100644 controller/auth_flow_test.go create mode 100644 controller/auth_session.go create mode 100644 controller/auth_session_test.go create mode 100644 controller/passkey_test.go create mode 100644 controller/user_manage_test.go create mode 100644 docs/authentication.md create mode 100644 middleware/auth_origin.go create mode 100644 middleware/auth_origin_test.go create mode 100644 middleware/auth_test.go create mode 100644 model/auth_flow.go create mode 100644 model/auth_flow_test.go create mode 100644 model/external_identity_claim.go create mode 100644 model/external_identity_claim_test.go create mode 100644 model/subscription_auth_test.go create mode 100644 model/user_auth_cache.go create mode 100644 model/user_cache_auth_version_test.go create mode 100644 model/user_session.go create mode 100644 model/user_session_test.go create mode 100644 service/auth_cleanup.go create mode 100644 service/auth_session.go create mode 100644 service/auth_session_test.go create mode 100644 service/auth_token.go create mode 100644 service/auth_token_test.go create mode 100644 web/default/src/features/auth/api.test.ts create mode 100644 web/default/src/features/auth/lib/oauth-bind-window.test.ts create mode 100644 web/default/src/features/auth/lib/oauth-bind-window.ts create mode 100644 web/default/src/features/playground/hooks/use-stream-request.test.ts create mode 100644 web/default/src/features/profile/components/login-session-dialogs.tsx create mode 100644 web/default/src/features/profile/components/login-session-item.tsx create mode 100644 web/default/src/features/profile/components/login-session-utils.test.ts create mode 100644 web/default/src/features/profile/components/login-session-utils.ts create mode 100644 web/default/src/features/profile/components/login-sessions-card.tsx create mode 100644 web/default/src/lib/auth-session-sync.ts create mode 100644 web/default/src/lib/auth-session.test.ts create mode 100644 web/default/src/lib/auth-session.ts create mode 100644 web/default/src/lib/http-client.ts diff --git a/.env.example b/.env.example index a63ed7668e98..ffea953c46b7 100644 --- a/.env.example +++ b/.env.example @@ -69,7 +69,10 @@ # 会话密钥 # SESSION_SECRET=random_string -# 启用 Secure session cookie,必须同时配置可信 HTTPS 入口地址;多个地址用英文逗号分隔 +# false/未配置:本地 HTTP 模式,关闭 refresh/logout OriginGuard,且不得设置 TRUSTED_URL;兼容本地开发代理。 +# true:启用 Secure Refresh Cookie 和严格 OriginGuard,必须同时列出全部可信 HTTPS Origin。 +# SESSION_COOKIE_TRUSTED_URL 多项用英文逗号分隔;不支持通配符、路径或域名后缀匹配。 +# 这些设置不修改 relay CORS。 # SESSION_COOKIE_SECURE=false # SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com diff --git a/README.en.md b/README.en.md index 35e8d82be0ac..323baa3d1b38 100644 --- a/README.en.md +++ b/README.en.md @@ -306,7 +306,9 @@ docker run --name new-api -d --restart always \ | Variable Name | Description | Default Value | |--------|------|--------| -| `SESSION_SECRET` | Session secret (required for multi-machine deployment) | - | +| `SESSION_SECRET` | Authentication signing secret; must be identical on every node | - | +| `SESSION_COOKIE_SECURE` | `false`/unset disables the refresh/logout OriginGuard for local HTTP dev proxies; `true` enables the Secure cookie and strict Origin checks | `false` | +| `SESSION_COOKIE_TRUSTED_URL` | Required with Secure mode: comma-separated exact HTTPS Origins allowed to call refresh/logout; not a relay CORS allowlist | - | | `CRYPTO_SECRET` | Encryption secret (required for Redis) | - | | `SQL_DSN` | Database connection string | - | | `REDIS_CONN_STRING` | Redis connection string | - | @@ -388,9 +390,11 @@ docker run --name new-api -d --restart always \ ### ⚠️ Multi-machine Deployment Considerations > [!WARNING] -> - **Must set** `SESSION_SECRET` - Otherwise login status inconsistent +> - **Must set the same** `SESSION_SECRET` on every node - Otherwise Access Tokens, refresh sessions and temporary authentication flows cannot be verified consistently > - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted +See [User authentication and login sessions](./docs/authentication.md) for the token, Origin-check and PAT contracts. + ### 🔄 Channel Retry and Cache **Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count` diff --git a/README.fr.md b/README.fr.md index 1a6d8e4635fe..6fcff93ecb50 100644 --- a/README.fr.md +++ b/README.fr.md @@ -313,7 +313,9 @@ docker run --name new-api -d --restart always \ | Nom de variable | Description | Valeur par défaut | |--------|------|--------| -| `SESSION_SECRET` | Secret de session (requis pour le déploiement multi-machines) | +| `SESSION_SECRET` | Secret de signature d’authentification, identique sur tous les nœuds | - | +| `SESSION_COOKIE_SECURE` | `false`/non défini désactive l’OriginGuard de refresh/logout pour les proxys HTTP locaux ; `true` active le cookie Secure et le contrôle strict de l’Origin | `false` | +| `SESSION_COOKIE_TRUSTED_URL` | Obligatoire en mode Secure : Origins HTTPS exactes autorisées pour refresh/logout, séparées par des virgules ; ce n’est pas une liste CORS relay | - | | `CRYPTO_SECRET` | Secret de chiffrement (requis pour Redis) | - | | `SQL_DSN` | Chaine de connexion à la base de données | - | | `REDIS_CONN_STRING` | Chaine de connexion Redis | - | @@ -395,9 +397,11 @@ docker run --name new-api -d --restart always \ ### ⚠️ Considérations sur le déploiement multi-machines > [!WARNING] -> - **Doit définir** `SESSION_SECRET` - Sinon l'état de connexion sera incohérent sur plusieurs machines +> - **La même valeur** `SESSION_SECRET` doit être définie sur chaque nœud, sinon les Access Tokens, sessions Refresh et flux temporaires ne peuvent pas être vérifiés de façon cohérente > - **Redis partagé doit définir** `CRYPTO_SECRET` - Sinon les données ne pourront pas être déchiffrées +Consultez [Authentification utilisateur et sessions de connexion](./docs/authentication.md) pour les contrats de token, de vérification Origin et de PAT. + ### 🔄 Nouvelle tentative de canal et cache **Configuration de la nouvelle tentative:** `Paramètres → Paramètres de fonctionnement → Paramètres généraux → Nombre de tentatives en cas d'échec` diff --git a/README.ja.md b/README.ja.md index e0702a7d4631..bbd8a31c96a4 100644 --- a/README.ja.md +++ b/README.ja.md @@ -315,7 +315,9 @@ docker run --name new-api -d --restart always \ | 変数名 | 説明 | デフォルト値 | |--------|------|--------| -| `SESSION_SECRET` | セッションシークレット(マルチマシンデプロイに必須) | - | +| `SESSION_SECRET` | 認証署名シークレット。すべてのノードで同じ値が必要 | - | +| `SESSION_COOKIE_SECURE` | `false`/未設定ではローカル HTTP 開発プロキシ向けに refresh/logout の OriginGuard を無効化し、`true` では Secure Cookie と厳格な Origin 検証を有効化 | `false` | +| `SESSION_COOKIE_TRUSTED_URL` | Secure モードでは必須。refresh/logout を許可する完全一致の HTTPS Origin をカンマ区切りで指定。relay CORS 設定ではありません | - | | `CRYPTO_SECRET` | 暗号化シークレット(Redisに必須) | - | | `SQL_DSN** | データベース接続文字列 | - | | `REDIS_CONN_STRING` | Redis接続文字列 | - | @@ -395,9 +397,11 @@ docker run --name new-api -d --restart always \ ### ⚠️ マルチマシンデプロイの注意事項 > [!WARNING] -> - **必ず設定する必要があります** `SESSION_SECRET` - そうしないとマルチマシンデプロイ時にログイン状態が不一致になります +> - すべてのノードに**同じ** `SESSION_SECRET` を設定してください。異なる場合、Access Token、Refresh セッション、一時認証フローを一貫して検証できません > - **共有Redisは必ず設定する必要があります** `CRYPTO_SECRET` - そうしないとデータを復号化できません +Token、Origin 検証、PAT の契約については[ユーザー認証とログインセッション](./docs/authentication.md)を参照してください。 + ### 🔄 チャネルリトライとキャッシュ **リトライ設定:** `設定 → 運営設定 → 一般設定 → 失敗リトライ回数` diff --git a/README.md b/README.md index 65e3facdb24e..2e1848591672 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,9 @@ docker run --name new-api -d --restart always \ | Variable Name | Description | Default Value | |--------|------|--------| -| `SESSION_SECRET` | Session secret (required for multi-machine deployment) | - | +| `SESSION_SECRET` | Authentication signing secret; must be identical on every node | - | +| `SESSION_COOKIE_SECURE` | `false`/unset disables the refresh/logout OriginGuard for local HTTP dev proxies; `true` enables the Secure cookie and strict Origin checks | `false` | +| `SESSION_COOKIE_TRUSTED_URL` | Required with Secure mode: comma-separated exact HTTPS Origins allowed to call refresh/logout; not a relay CORS allowlist | - | | `CRYPTO_SECRET` | Encryption secret (required for Redis) | - | | `SQL_DSN` | Database connection string | - | | `REDIS_CONN_STRING` | Redis connection string | - | @@ -396,9 +398,11 @@ docker run --name new-api -d --restart always \ ### ⚠️ Multi-machine Deployment Considerations > [!WARNING] -> - **Must set** `SESSION_SECRET` - Otherwise login status inconsistent +> - **Must set the same** `SESSION_SECRET` on every node - Otherwise Access Tokens, refresh sessions and temporary authentication flows cannot be verified consistently > - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted +See [User authentication and login sessions](./docs/authentication.md) for the token, Origin-check and PAT contracts. + ### 🔄 Channel Retry and Cache **Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count` diff --git a/README.zh_CN.md b/README.zh_CN.md index 33878843f365..fa19497daed6 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -313,7 +313,9 @@ docker run --name new-api -d --restart always \ | 变量名 | 说明 | 默认值 | |--------|--------------------------------------------------------------|--------| -| `SESSION_SECRET` | 会话密钥(多机部署必须) | - | +| `SESSION_SECRET` | 鉴权签名密钥;所有节点必须保持一致 | - | +| `SESSION_COOKIE_SECURE` | `false`/未配置时关闭 refresh/logout OriginGuard 以兼容本地 HTTP 开发代理;`true` 时启用 Secure Cookie 和严格 Origin 校验 | `false` | +| `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:允许调用 refresh/logout 的精确 HTTPS Origin,多个用英文逗号分隔;不是 relay CORS 白名单 | - | | `CRYPTO_SECRET` | 加密密钥(Redis 必须) | - | | `SQL_DSN` | 数据库连接字符串 | - | | `REDIS_CONN_STRING` | Redis 连接字符串 | - | @@ -395,9 +397,11 @@ docker run --name new-api -d --restart always \ ### ⚠️ 多机部署注意事项 > [!WARNING] -> - **必须设置** `SESSION_SECRET` - 否则登录状态不一致 +> - 所有节点**必须设置相同的** `SESSION_SECRET` - 否则 Access Token、Refresh 会话和临时鉴权流程无法一致校验 > - **公用 Redis 必须设置** `CRYPTO_SECRET` - 否则数据无法解密 +Token、Origin 校验和 PAT 契约见[用户鉴权与登录会话](./docs/authentication.md)。 + ### 🔄 渠道重试与缓存 **重试配置:** `设置 → 运营设置 → 通用设置 → 失败重试次数` diff --git a/README.zh_TW.md b/README.zh_TW.md index 0845d5acbb81..95d04e3468fb 100644 --- a/README.zh_TW.md +++ b/README.zh_TW.md @@ -313,7 +313,9 @@ docker run --name new-api -d --restart always \ | 變數名 | 說明 | 預設值 | |--------|--------------------------------------------------------------|--------| -| `SESSION_SECRET` | 會話密鑰(多機部署必須) | - | +| `SESSION_SECRET` | 鑑權簽章密鑰;所有節點必須保持一致 | - | +| `SESSION_COOKIE_SECURE` | `false`/未設定時關閉 refresh/logout OriginGuard 以相容本機 HTTP 開發代理;`true` 時啟用 Secure Cookie 和嚴格 Origin 驗證 | `false` | +| `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:允許呼叫 refresh/logout 的精確 HTTPS Origin,多個值以英文逗號分隔;不是 relay CORS 白名單 | - | | `CRYPTO_SECRET` | 加密密鑰(Redis 必須) | - | | `SQL_DSN` | 資料庫連接字符串 | - | | `REDIS_CONN_STRING` | Redis 連接字符串 | - | @@ -395,9 +397,11 @@ docker run --name new-api -d --restart always \ ### ⚠️ 多機部署注意事項 > [!WARNING] -> - **必須設置** `SESSION_SECRET` - 否則登錄狀態不一致 +> - 所有節點**必須設定相同的** `SESSION_SECRET` - 否則 Access Token、Refresh 工作階段和臨時鑑權流程無法一致驗證 > - **公用 Redis 必須設置** `CRYPTO_SECRET` - 否則數據無法解密 +Token、Origin 驗證和 PAT 契約請參閱[使用者鑑權與登入工作階段](./docs/authentication.md)。 + ### 🔄 管道重試與快取 **重試配置:** `設置 → 運營設置 → 通用設置 → 失敗重試次數` diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md index 4b8b1b173945..e95e5542423f 100644 --- a/THIRD-PARTY-LICENSES.md +++ b/THIRD-PARTY-LICENSES.md @@ -12,6 +12,7 @@ Transitive dependencies should be audited before a final external release. |-------------|-------------|-----------|-------------------------------------------------------|--------------------------------------|----------------------------------------------------| | backend | production | Go | `github.com/Calcium-Ion/go-epay` | `v0.0.4` | Proprietary/Internal - owned by project maintainer | | backend | production | Go | `github.com/abema/go-mp4` | `v1.4.1` | MIT | +| backend | test | Go | `github.com/alicebob/miniredis/v2` | `v2.38.0` | MIT | | backend | production | Go | `github.com/andybalholm/brotli` | `v1.1.1` | MIT | | backend | production | Go | `github.com/anknown/ahocorasick` | `v0.0.0-20190904063843-d75dbd5169c0` | MIT | | backend | production | Go | `github.com/aws/aws-sdk-go-v2` | `v1.41.5` | Apache-2.0 | @@ -21,7 +22,6 @@ Transitive dependencies should be audited before a final external release. | backend | production | Go | `github.com/bytedance/gopkg` | `v0.1.3` | Apache-2.0 | | backend | production | Go | `github.com/gin-contrib/cors` | `v1.7.2` | MIT | | backend | production | Go | `github.com/gin-contrib/gzip` | `v0.0.6` | MIT | -| backend | production | Go | `github.com/gin-contrib/sessions` | `v0.0.5` | MIT | | backend | production | Go | `github.com/gin-contrib/static` | `v0.0.1` | MIT | | backend | production | Go | `github.com/gin-gonic/gin` | `v1.9.1` | MIT | | backend | production | Go | `github.com/glebarez/sqlite` | `v1.9.0` | MIT | @@ -372,4 +372,3 @@ this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. For more information, please refer to https://unlicense.org/ - diff --git a/common/session_cookie.go b/common/session_cookie.go index 74e3e25cb38e..28981ce9d817 100644 --- a/common/session_cookie.go +++ b/common/session_cookie.go @@ -2,11 +2,45 @@ package common import ( "fmt" + "net" "net/url" "os" "strings" ) +// NormalizeOrigin validates and canonicalizes a browser origin. Only an exact +// scheme/host/effective-port match is meaningful; paths and wildcards are not +// accepted for authentication cookie endpoints. +func NormalizeOrigin(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "null" || strings.ContainsAny(raw, "\r\n") { + return "", fmt.Errorf("origin is empty or invalid") + } + parsedURL, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("invalid origin: %w", err) + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return "", fmt.Errorf("origin scheme must be http or https") + } + if parsedURL.Host == "" || parsedURL.User != nil || parsedURL.RawQuery != "" || parsedURL.Fragment != "" || (parsedURL.Path != "" && parsedURL.Path != "/") { + return "", fmt.Errorf("origin must contain only scheme and host") + } + hostname := strings.ToLower(parsedURL.Hostname()) + if hostname == "" || strings.Contains(hostname, "*") { + return "", fmt.Errorf("origin host is empty") + } + port := parsedURL.Port() + normalizedHost := hostname + if strings.Contains(hostname, ":") { + normalizedHost = "[" + hostname + "]" + } + if port == "" || (parsedURL.Scheme == "http" && port == "80") || (parsedURL.Scheme == "https" && port == "443") { + return parsedURL.Scheme + "://" + normalizedHost, nil + } + return parsedURL.Scheme + "://" + net.JoinHostPort(hostname, port), nil +} + func InitSessionCookieSettings() error { secureRaw := strings.TrimSpace(os.Getenv("SESSION_COOKIE_SECURE")) trustedURLsRaw := strings.TrimSpace(os.Getenv("SESSION_COOKIE_TRUSTED_URL")) @@ -35,14 +69,14 @@ func InitSessionCookieSettings() error { if trustedURL == "" { return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL contains an empty URL") } - parsedURL, err := url.Parse(trustedURL) + normalizedOrigin, err := NormalizeOrigin(trustedURL) if err != nil { return fmt.Errorf("invalid SESSION_COOKIE_TRUSTED_URL: %w", err) } - if parsedURL.Scheme != "https" || parsedURL.Host == "" { + if !strings.HasPrefix(normalizedOrigin, "https://") { return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL must contain only https URLs with hosts") } - SessionCookieTrustedURLs = append(SessionCookieTrustedURLs, trustedURL) + SessionCookieTrustedURLs = append(SessionCookieTrustedURLs, normalizedOrigin) } SessionCookieSecure = true diff --git a/common/sys_log.go b/common/sys_log.go index 1fa4ebb57516..2d6022f774eb 100644 --- a/common/sys_log.go +++ b/common/sys_log.go @@ -47,9 +47,10 @@ func LogStartupSuccess(startTime time.Time, port string) { defer LogWriterMu.RUnlock() if SessionCookieSecure == false { - // log warning if session cookie is not secure + // Warn when the local HTTP compatibility mode disables cookie transport + // security and refresh/logout Origin validation. fmt.Fprintf(gin.DefaultWriter, "\n") - fmt.Fprintf(gin.DefaultWriter, " \033[33mWarning: Session cookie is not secure. Please set SESSION_COOKIE_SECURE=true in production.\033[0m\n") + fmt.Fprintf(gin.DefaultWriter, " \033[33mWarning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production.\033[0m\n") fmt.Fprintf(gin.DefaultWriter, "\n") } diff --git a/common/url_validator_test.go b/common/url_validator_test.go index 478832a3311b..4342500cf945 100644 --- a/common/url_validator_test.go +++ b/common/url_validator_test.go @@ -193,3 +193,29 @@ func TestInitSessionCookieSettingsRejectsEmptyTrustedURLInList(t *testing.T) { require.Error(t, InitSessionCookieSettings()) } + +func TestInitSessionCookieSettingsNormalizesExactOrigins(t *testing.T) { + resetSessionCookieSettingsAfterTest(t) + t.Setenv("SESSION_COOKIE_SECURE", "true") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://EXAMPLE.com:443,https://admin.example.com:8443/") + + require.NoError(t, InitSessionCookieSettings()) + assert.Equal(t, []string{"https://example.com", "https://admin.example.com:8443"}, SessionCookieTrustedURLs) +} + +func TestInitSessionCookieSettingsRejectsNonOriginURLs(t *testing.T) { + for _, trustedURL := range []string{ + "https://*.example.com", + "https://user@example.com", + "https://example.com/admin", + "https://example.com?next=admin", + "https://example.com#admin", + } { + t.Run(trustedURL, func(t *testing.T) { + resetSessionCookieSettingsAfterTest(t) + t.Setenv("SESSION_COOKIE_SECURE", "true") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", trustedURL) + require.Error(t, InitSessionCookieSettings()) + }) + } +} diff --git a/controller/auth_flow_test.go b/controller/auth_flow_test.go new file mode 100644 index 000000000000..3fe08b427830 --- /dev/null +++ b/controller/auth_flow_test.go @@ -0,0 +1,224 @@ +package controller + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/oauth" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type authFlowTestOAuthProvider struct { + exchangeErr error + userInfoErr error + exchangeCalls int + userInfoCalls int +} + +func (*authFlowTestOAuthProvider) GetName() string { return "Auth Flow Test" } +func (*authFlowTestOAuthProvider) IsEnabled() bool { return true } +func (provider *authFlowTestOAuthProvider) ExchangeToken(context.Context, string, *gin.Context) (*oauth.OAuthToken, error) { + provider.exchangeCalls++ + if provider.exchangeErr != nil { + return nil, provider.exchangeErr + } + return &oauth.OAuthToken{}, nil +} +func (provider *authFlowTestOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) { + provider.userInfoCalls++ + if provider.userInfoErr != nil { + return nil, provider.userInfoErr + } + return &oauth.OAuthUser{ProviderUserID: "external-user"}, nil +} +func (*authFlowTestOAuthProvider) IsUserIDTaken(string) bool { return false } +func (*authFlowTestOAuthProvider) FillUserByProviderID(*model.User, string) error { return nil } +func (*authFlowTestOAuthProvider) SetProviderUserID(*model.User, string) {} +func (*authFlowTestOAuthProvider) GetProviderPrefix() string { return "flow_" } + +func setupAuthFlowControllerTest(t *testing.T) *authFlowTestOAuthProvider { + t.Helper() + previousDB := model.DB + previousType := common.MainDatabaseType() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.AuthFlow{})) + model.DB = db + common.SetMainDatabaseType(common.DatabaseTypeSQLite) + provider := &authFlowTestOAuthProvider{} + oauth.Register("auth-flow-test", provider) + t.Cleanup(func() { + oauth.Unregister("auth-flow-test") + model.DB = previousDB + common.SetMainDatabaseType(previousType) + }) + return provider +} + +func TestGenerateOAuthCodeCarriesAffiliateInLoginFlow(t *testing.T) { + setupAuthFlowControllerTest(t) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"auth-flow-test","intent":"login","aff":"invite-code"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + GenerateOAuthCode(c) + + require.Equal(t, http.StatusOK, recorder.Code) + var response struct { + Success bool `json:"success"` + Data struct { + FlowToken string `json:"flow_token"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Success) + flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{ + Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin, + }) + require.NoError(t, err) + var payload oauthFlowPayload + require.NoError(t, common.UnmarshalJsonStr(flow.Payload, &payload)) + assert.Equal(t, "invite-code", payload.AffiliateCode) + assert.Zero(t, flow.UserId) + assert.Empty(t, flow.SessionId) +} + +func TestGenerateOAuthCodeBindsFlowToAuthenticatedSession(t *testing.T) { + setupAuthFlowControllerTest(t) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"auth-flow-test","intent":"bind"}`)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("id", 42) + c.Set("session_id", "session-42") + c.Set("auth_version", int64(3)) + c.Set("session_version", int64(2)) + + GenerateOAuthCode(c) + + require.Equal(t, http.StatusOK, recorder.Code) + var response struct { + Success bool `json:"success"` + Data struct { + FlowToken string `json:"flow_token"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Success) + flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{ + Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind, + UserId: 42, SessionId: "session-42", + }) + require.NoError(t, err) + assert.Equal(t, 42, flow.UserId) + assert.Equal(t, "session-42", flow.SessionId) +} + +func TestOAuthLoginConsumesFlowOnlyAfterProviderIdentity(t *testing.T) { + provider := setupAuthFlowControllerTest(t) + + tests := []struct { + name string + exchangeErr error + userInfoErr error + }{ + {name: "exchange failure", exchangeErr: errors.New("exchange failed")}, + {name: "user info failure", userInfoErr: errors.New("user info failed")}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + provider.exchangeErr = test.exchangeErr + provider.userInfoErr = test.userInfoErr + token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin, + Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + + router := gin.New() + router.GET("/api/oauth/:provider", HandleOAuth) + request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+token+"&code=test", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{ + Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin, + }) + require.NoError(t, err) + assert.Nil(t, flow.ConsumedAt) + }) + } +} + +func TestOAuthLoginConsumesFlowAfterProviderIdentityAndOnProviderError(t *testing.T) { + provider := setupAuthFlowControllerTest(t) + + provider.exchangeErr = nil + provider.userInfoErr = nil + successToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin, + Payload: `{invalid`, ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + router := gin.New() + router.GET("/api/oauth/:provider", HandleOAuth) + request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+successToken+"&code=test", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + _, err = model.GetAuthFlow(successToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth}) + assert.ErrorIs(t, err, model.ErrAuthFlowConsumed) + assert.Equal(t, 1, provider.exchangeCalls) + assert.Equal(t, 1, provider.userInfoCalls) + + providerErrorToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin, + Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + request = httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+providerErrorToken+"&error=access_denied", nil) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + _, err = model.GetAuthFlow(providerErrorToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth}) + assert.ErrorIs(t, err, model.ErrAuthFlowConsumed) + assert.Equal(t, 1, provider.exchangeCalls) + assert.Equal(t, 1, provider.userInfoCalls) +} + +func TestOAuthBindProviderErrorConsumesSessionBoundFlow(t *testing.T) { + provider := setupAuthFlowControllerTest(t) + flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind, + UserId: 42, SessionId: "session-42", Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("id", 42) + c.Set("session_id", "session-42") + c.Set("auth_version", int64(1)) + c.Set("session_version", int64(1)) + c.Next() + }) + router.GET("/api/oauth/:provider", HandleOAuth) + request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+flowToken+"&error=access_denied&error_description=cancelled", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + assert.Equal(t, http.StatusOK, response.Code) + _, err = model.GetAuthFlow(flowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth}) + assert.ErrorIs(t, err, model.ErrAuthFlowConsumed) + assert.Zero(t, provider.exchangeCalls) + assert.Zero(t, provider.userInfoCalls) +} diff --git a/controller/auth_session.go b/controller/auth_session.go new file mode 100644 index 000000000000..0f1599c596a1 --- /dev/null +++ b/controller/auth_session.go @@ -0,0 +1,189 @@ +package controller + +import ( + "errors" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func RefreshAuth(c *gin.Context) { + setAuthNoStore(c) + rawRefreshToken, err := c.Cookie(service.RefreshCookieName) + if err != nil || rawRefreshToken == "" { + service.ClearRefreshCookie(c) + writeAuthSessionError(c, service.ErrRefreshTokenInvalid) + return + } + bundle, user, err := service.RefreshLoginSession(rawRefreshToken, c.GetHeader("X-Auth-Session"), c.ClientIP(), c.Request.UserAgent()) + if err != nil { + if errors.Is(err, service.ErrRefreshTokenInvalid) || errors.Is(err, service.ErrLoginSessionRevoked) { + service.ClearRefreshCookie(c) + } + writeAuthSessionError(c, err) + return + } + service.WriteRefreshCookie(c, bundle.RefreshToken) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "access_token": bundle.AccessToken, + "token_type": bundle.TokenType, + "access_expires_at": bundle.AccessExpiresAt, + "user": buildSelfUserData(user), + "session": bundle.Session, + }, + }) +} + +func AuthLogout(c *gin.Context) { + setAuthNoStore(c) + expectedSID := strings.TrimSpace(c.GetHeader("X-Auth-Session")) + rawRefreshToken, cookieErr := c.Cookie(service.RefreshCookieName) + cookieSID, hasCookieSID := service.RefreshTokenSID(rawRefreshToken) + if expectedSID != "" && cookieErr == nil && hasCookieSID && cookieSID != expectedSID { + writeAuthSessionError(c, service.ErrLoginSessionMismatch) + return + } + + if rawAccessToken, ok := dashboardBearer(c.GetHeader("Authorization")); ok { + if identity, err := service.ParseAccessToken(rawAccessToken); err == nil { + if expectedSID != "" && expectedSID != identity.SessionID { + writeAuthSessionError(c, service.ErrLoginSessionMismatch) + return + } + if _, err := model.RevokeUserSession(identity.UserID, identity.SessionID, "logout"); err != nil { + writeAuthSessionError(c, err) + return + } + cookieCleared := false + if cookieErr == nil && hasCookieSID && cookieSID == identity.SessionID { + if err := service.RevokeByRefreshToken(rawRefreshToken, identity.SessionID, "logout"); err != nil { + writeAuthSessionError(c, err) + return + } + service.ClearRefreshCookie(c) + cookieCleared = true + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{"revoked_sid": identity.SessionID, "cookie_cleared": cookieCleared}, + }) + return + } + } + if cookieErr != nil || rawRefreshToken == "" { + service.ClearRefreshCookie(c) + c.JSON(http.StatusOK, gin.H{"success": true, "message": ""}) + return + } + if err := service.RevokeByRefreshToken(rawRefreshToken, expectedSID, "logout"); err != nil { + writeAuthSessionError(c, err) + return + } + service.ClearRefreshCookie(c) + c.JSON(http.StatusOK, gin.H{"success": true, "message": ""}) +} + +func GetLoginSessions(c *gin.Context) { + identity, ok := requireBrowserSession(c) + if !ok { + return + } + sessions, err := service.ListLoginSessions(identity.UserID, identity.SessionID) + if err != nil { + writeAuthSessionError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": sessions}) +} + +func DeleteLoginSession(c *gin.Context) { + identity, ok := requireBrowserSession(c) + if !ok { + return + } + sid := strings.TrimSpace(c.Param("sid")) + if sid == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "AUTH_SESSION_ID_REQUIRED", "message": "session id is required"}) + return + } + revoked, err := model.RevokeUserSession(identity.UserID, sid, "user_revoked") + if err != nil { + writeAuthSessionError(c, err) + return + } + if !revoked { + c.JSON(http.StatusNotFound, gin.H{"success": false, "code": "AUTH_SESSION_NOT_FOUND", "message": "session not found"}) + return + } + if rawRefreshToken, cookieErr := c.Cookie(service.RefreshCookieName); cookieErr == nil { + cookieSID, ok := service.RefreshTokenSID(rawRefreshToken) + if ok && cookieSID == sid { + service.ClearRefreshCookie(c) + } + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"revoked_sid": sid, "current": sid == identity.SessionID}}) +} + +func RevokeOtherLoginSessions(c *gin.Context) { + identity, ok := requireBrowserSession(c) + if !ok { + return + } + count, err := model.RevokeOtherUserSessions(identity.UserID, identity.SessionID, "user_revoked_others") + if err != nil { + writeAuthSessionError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"revoked_count": count}}) +} + +func requireBrowserSession(c *gin.Context) (service.AuthIdentity, bool) { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "code": "AUTH_SESSION_REQUIRED", + "message": "a dashboard login session is required", + }) + return service.AuthIdentity{}, false + } + return identity, true +} + +func writeAuthSessionError(c *gin.Context, err error) { + status, code := service.AuthSessionErrorCode(err) + if errors.Is(err, gorm.ErrRecordNotFound) { + status, code = http.StatusUnauthorized, "AUTH_UNAUTHORIZED" + } + c.JSON(status, gin.H{"success": false, "code": code, "message": http.StatusText(status)}) +} + +func setAuthNoStore(c *gin.Context) { + c.Header("Cache-Control", "no-store") +} + +func authRotationData(bundle *service.AuthBundle) gin.H { + return gin.H{ + "access_token": bundle.AccessToken, + "token_type": bundle.TokenType, + "access_expires_at": bundle.AccessExpiresAt, + "session": bundle.Session, + } +} + +func dashboardBearer(header string) (string, bool) { + parts := strings.Fields(header) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" { + return "", false + } + return parts[1], true +} diff --git a/controller/auth_session_test.go b/controller/auth_session_test.go new file mode 100644 index 000000000000..706f076c0c80 --- /dev/null +++ b/controller/auth_session_test.go @@ -0,0 +1,67 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestAuthLogoutRejectsRefreshCookieSessionMismatch(t *testing.T) { + previousDB := model.DB + previousRedis := common.RedisEnabled + previousSecret := common.SessionSecret + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{})) + model.DB = db + common.RedisEnabled = false + common.SessionSecret = "auth-logout-mismatch-test-secret" + t.Cleanup(func() { + model.DB = previousDB + common.RedisEnabled = previousRedis + common.SessionSecret = previousSecret + }) + + user := &model.User{ + Username: "logout-mismatch-user", Password: "unused", Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, + } + require.NoError(t, db.Create(user).Error) + sessionA, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "agent-a") + require.NoError(t, err) + sessionB, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "agent-b") + require.NoError(t, err) + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/user/auth/logout", nil) + c.Request.Header.Set("Authorization", "Bearer "+sessionA.AccessToken) + c.Request.Header.Set("X-Auth-Session", sessionA.Session.SID) + c.Request.AddCookie(&http.Cookie{Name: service.RefreshCookieName, Value: sessionB.RefreshToken}) + + AuthLogout(c) + + assert.Equal(t, http.StatusConflict, recorder.Code) + var response struct { + Success bool `json:"success"` + Code string `json:"code"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + assert.False(t, response.Success) + assert.Equal(t, "AUTH_SESSION_MISMATCH", response.Code) + for _, sid := range []string{sessionA.Session.SID, sessionB.Session.SID} { + stored, err := model.GetUserSessionBySID(sid) + require.NoError(t, err) + assert.Equal(t, model.UserSessionStatusActive, stored.Status) + } +} diff --git a/controller/model_list_test.go b/controller/model_list_test.go index 55334b1bf43c..3077948d481a 100644 --- a/controller/model_list_test.go +++ b/controller/model_list_test.go @@ -14,8 +14,6 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting/config" "github.com/QuantumNous/new-api/setting/operation_setting" - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" @@ -417,7 +415,7 @@ func TestCheckUpdatePasswordRejectsHistoricalEmptyPassword(t *testing.T) { func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) { db := setupModelListControllerTestDB(t) - require.NoError(t, db.AutoMigrate(&model.Log{})) + require.NoError(t, db.AutoMigrate(&model.Log{}, &model.UserSession{})) hashedPassword, err := common.Password2Hash("CurrentPassword123") require.NoError(t, err) @@ -431,8 +429,6 @@ func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) { require.NoError(t, db.Create(user).Error) router := gin.New() - store := cookie.NewStore([]byte("test-session-secret")) - router.Use(sessions.Sessions("session", store)) router.GET("/", func(c *gin.Context) { setupLogin(&model.User{ Id: user.Id, diff --git a/controller/oauth.go b/controller/oauth.go index 9ada6ddd261e..a477f5b1d035 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -5,16 +5,30 @@ import ( "fmt" "net/http" "strconv" + "strings" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "gorm.io/gorm" ) +const oauthAuthFlowTTL = 10 * time.Minute + +type oauthStateRequest struct { + Provider string `json:"provider"` + Intent string `json:"intent"` + Aff string `json:"aff,omitempty"` +} + +type oauthFlowPayload struct { + AffiliateCode string `json:"affiliate_code,omitempty"` +} + // providerParams returns map with Provider key for i18n templates func providerParams(name string) map[string]any { return map[string]any{"Provider": name} @@ -22,14 +36,47 @@ func providerParams(name string) map[string]any { // GenerateOAuthCode generates a state code for OAuth CSRF protection func GenerateOAuthCode(c *gin.Context) { - session := sessions.Default(c) - state := common.GetRandomString(12) - affCode := c.Query("aff") - if affCode != "" { - session.Set("aff", affCode) - } - session.Set("oauth_state", state) - err := session.Save() + var request oauthStateRequest + if err := common.DecodeJson(c.Request.Body, &request); err != nil { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + request.Provider = strings.TrimSpace(request.Provider) + request.Intent = strings.TrimSpace(request.Intent) + request.Aff = strings.TrimSpace(request.Aff) + if oauth.GetProvider(request.Provider) == nil || + (request.Intent != model.AuthFlowIntentLogin && request.Intent != model.AuthFlowIntentBind) || + len(request.Aff) > 32 || + (request.Intent == model.AuthFlowIntentBind && request.Aff != "") { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + userID := 0 + sessionID := "" + if request.Intent == model.AuthFlowIntentBind { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "绑定操作需要登录"}) + return + } + userID = identity.UserID + sessionID = identity.SessionID + } + payload, err := common.Marshal(oauthFlowPayload{AffiliateCode: request.Aff}) + if err != nil { + common.ApiError(c, err) + return + } + expiresAt := time.Now().Add(oauthAuthFlowTTL) + state, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeOAuth, + Provider: request.Provider, + Intent: request.Intent, + UserId: userID, + SessionId: sessionID, + Payload: string(payload), + ExpiresAt: expiresAt, + }) if err != nil { common.ApiError(c, err) return @@ -37,7 +84,10 @@ func GenerateOAuthCode(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": state, + "data": gin.H{ + "flow_token": state, + "expires_at": expiresAt.Unix(), + }, }) } @@ -53,11 +103,13 @@ func HandleOAuth(c *gin.Context) { return } - session := sessions.Default(c) - // 1. Validate state (CSRF protection) state := c.Query("state") - if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { + pendingFlow, err := model.GetAuthFlow(state, model.AuthFlowMatch{ + Purpose: model.AuthFlowPurposeOAuth, + Provider: providerName, + }) + if err != nil { c.JSON(http.StatusForbidden, gin.H{ "success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid), @@ -65,10 +117,25 @@ func HandleOAuth(c *gin.Context) { return } - // 2. Check if user is already logged in (bind flow) - username := session.Get("username") - if username != nil { - handleOAuthBind(c, provider) + consumeMatch := model.AuthFlowMatch{ + Purpose: model.AuthFlowPurposeOAuth, + Provider: providerName, + Intent: pendingFlow.Intent, + } + // 2. Bind flows are bound to the live dashboard Session that created them. + if pendingFlow.Intent == model.AuthFlowIntentBind { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok || identity.UserID != pendingFlow.UserId || identity.SessionID != pendingFlow.SessionId { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": i18n.T(c, i18n.MsgOAuthStateInvalid), + }) + return + } + consumeMatch.UserId = identity.UserID + consumeMatch.SessionId = identity.SessionID + } else if pendingFlow.Intent != model.AuthFlowIntentLogin { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } @@ -81,13 +148,24 @@ func HandleOAuth(c *gin.Context) { // 4. Handle error from provider errorCode := c.Query("error") if errorCode != "" { + if _, err := model.ConsumeAuthFlow(state, consumeMatch); err != nil { + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)}) + return + } errorDescription := c.Query("error_description") + if errorDescription == "" { + errorDescription = errorCode + } c.JSON(http.StatusOK, gin.H{ "success": false, "message": errorDescription, }) return } + if pendingFlow.Intent == model.AuthFlowIntentBind { + handleOAuthBind(c, provider, pendingFlow, state) + return + } // 5. Exchange code for token code := c.Query("code") @@ -103,9 +181,19 @@ func HandleOAuth(c *gin.Context) { handleOAuthError(c, err) return } + flow, err := model.ConsumeAuthFlow(state, consumeMatch) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)}) + return + } // 7. Find or create user - user, err := findOrCreateOAuthUser(c, provider, oauthUser, session) + var payload oauthFlowPayload + if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil { + common.ApiError(c, err) + return + } + user, err := findOrCreateOAuthUser(c, provider, oauthUser, payload.AffiliateCode) if err != nil { if errors.Is(err, model.ErrEmailAlreadyTaken) { common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken) @@ -135,12 +223,7 @@ func HandleOAuth(c *gin.Context) { } // handleOAuthBind handles binding OAuth account to existing user -func handleOAuthBind(c *gin.Context, provider oauth.Provider) { - if !provider.IsEnabled() { - common.ApiErrorI18n(c, i18n.MsgOAuthNotEnabled, providerParams(provider.GetName())) - return - } - +func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model.AuthFlow, flowToken string) { // Exchange code for token code := c.Query("code") token, err := provider.ExchangeToken(c.Request.Context(), code, c) @@ -169,10 +252,18 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) { } } - // Get current user from session - session := sessions.Default(c) - id := session.Get("id") - user := model.User{Id: id.(int)} + if _, err := model.ConsumeAuthFlow(flowToken, model.AuthFlowMatch{ + Purpose: model.AuthFlowPurposeOAuth, + Provider: pendingFlow.Provider, + Intent: model.AuthFlowIntentBind, + UserId: pendingFlow.UserId, + SessionId: pendingFlow.SessionId, + }); err != nil { + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)}) + return + } + + user := model.User{Id: pendingFlow.UserId} err = user.FillUserById() if err != nil { common.ApiError(c, err) @@ -203,7 +294,7 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) { } // findOrCreateOAuthUser finds existing user or creates new user -func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, session sessions.Session) (*model.User, error) { +func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, affiliateCode string) (*model.User, error) { user := &model.User{} // Check if user already exists with new ID @@ -276,10 +367,9 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o user.Status = common.UserStatusEnabled // Handle affiliate code - affCode := session.Get("aff") inviterId := 0 - if affCode != nil { - inviterId, _ = model.GetUserIdByAffCode(affCode.(string)) + if affiliateCode != "" { + inviterId, _ = model.GetUserIdByAffCode(affiliateCode) } // Use transaction to ensure user creation and OAuth binding are atomic diff --git a/controller/passkey.go b/controller/passkey.go index 6c73b006a777..198df804b838 100644 --- a/controller/passkey.go +++ b/controller/passkey.go @@ -1,6 +1,7 @@ package controller import ( + "encoding/json" "errors" "fmt" "net/http" @@ -8,16 +9,43 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" passkeysvc "github.com/QuantumNous/new-api/service/passkey" "github.com/QuantumNous/new-api/setting/system_setting" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/go-webauthn/webauthn/protocol" webauthnlib "github.com/go-webauthn/webauthn/webauthn" ) +const ( + securityProofScopeChannelKeyRead = "channel.key.read" + securityProofScopePasskeyRegister = "passkey.register" + securityProofScopePasskeyDelete = "passkey.delete" +) + +type passkeyFinishRequest struct { + FlowToken string `json:"flow_token"` + Credential json.RawMessage `json:"credential"` +} + +type passkeyVerifyBeginRequest struct { + Scope string `json:"scope"` +} + +func parsePasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, error) { + var request passkeyFinishRequest + if err := common.DecodeJson(c.Request.Body, &request); err != nil { + return nil, err + } + if request.FlowToken == "" || len(request.Credential) == 0 { + return nil, errors.New("Passkey 流程参数不完整") + } + return &request, nil +} + func PasskeyRegisterBegin(c *gin.Context) { if !system_setting.GetPasskeySettings().Enabled { c.JSON(http.StatusOK, gin.H{ @@ -27,7 +55,7 @@ func PasskeyRegisterBegin(c *gin.Context) { return } - user, err := getSessionUser(c) + user, err := getAuthenticatedUser(c) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, @@ -68,7 +96,19 @@ func PasskeyRegisterBegin(c *gin.Context) { return } - if err := passkeysvc.SaveSessionData(c, passkeysvc.RegistrationSessionKey, sessionData); err != nil { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + return + } + flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow( + model.AuthFlowPurposePasskeyRegister, + user.Id, + identity.SessionID, + securityProofScopePasskeyRegister, + sessionData, + ) + if err != nil { common.ApiError(c, err) return } @@ -77,7 +117,9 @@ func PasskeyRegisterBegin(c *gin.Context) { "success": true, "message": "", "data": gin.H{ - "options": creation, + "options": creation, + "flow_token": flowToken, + "expires_at": expiresAt, }, }) } @@ -91,7 +133,7 @@ func PasskeyRegisterFinish(c *gin.Context) { return } - user, err := getSessionUser(c) + user, err := getAuthenticatedUser(c) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, @@ -99,11 +141,21 @@ func PasskeyRegisterFinish(c *gin.Context) { }) return } - if !requirePasskeyRegistrationVerification(c, user.Id) { return } + request, err := parsePasskeyFinishRequest(c) + if err != nil { + common.ApiError(c, err) + return + } + parsedCredential, err := protocol.ParseCredentialCreationResponseBytes(request.Credential) + if err != nil { + common.ApiError(c, err) + return + } + wa, err := passkeysvc.BuildWebAuthn(c.Request) if err != nil { common.ApiError(c, err) @@ -119,14 +171,24 @@ func PasskeyRegisterFinish(c *gin.Context) { credentialRecord = nil } - sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey) + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + return + } + sessionData, _, err := passkeysvc.PopSessionDataFlow( + request.FlowToken, + model.AuthFlowPurposePasskeyRegister, + user.Id, + identity.SessionID, + ) if err != nil { common.ApiError(c, err) return } waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord) - credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request) + credential, err := wa.CreateCredential(waUser, *sessionData, parsedCredential) if err != nil { common.ApiError(c, err) return @@ -138,7 +200,12 @@ func PasskeyRegisterFinish(c *gin.Context) { return } - if err := model.UpsertPasskeyCredential(passkeyCredential); err != nil { + if err := model.UpsertPasskeyCredentialWithAuthVersion(passkeyCredential); err != nil { + common.ApiError(c, err) + return + } + bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "passkey_registered") + if err != nil { common.ApiError(c, err) return } @@ -147,11 +214,12 @@ func PasskeyRegisterFinish(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Passkey 注册成功", + "data": authRotationData(bundle), }) } func PasskeyDelete(c *gin.Context) { - user, err := getSessionUser(c) + user, err := getAuthenticatedUser(c) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, @@ -164,7 +232,17 @@ func PasskeyDelete(c *gin.Context) { return } - if err := model.DeletePasskeyByUserID(user.Id); err != nil { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + return + } + if err := model.DeletePasskeyByUserIDWithAuthVersion(user.Id); err != nil { + common.ApiError(c, err) + return + } + bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "passkey_deleted") + if err != nil { common.ApiError(c, err) return } @@ -173,11 +251,12 @@ func PasskeyDelete(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Passkey 已解绑", + "data": authRotationData(bundle), }) } func PasskeyStatus(c *gin.Context) { - user, err := getSessionUser(c) + user, err := getAuthenticatedUser(c) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, @@ -235,7 +314,14 @@ func PasskeyLoginBegin(c *gin.Context) { return } - if err := passkeysvc.SaveSessionData(c, passkeysvc.LoginSessionKey, sessionData); err != nil { + flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow( + model.AuthFlowPurposePasskeyLogin, + 0, + "", + "", + sessionData, + ) + if err != nil { common.ApiError(c, err) return } @@ -244,7 +330,9 @@ func PasskeyLoginBegin(c *gin.Context) { "success": true, "message": "", "data": gin.H{ - "options": assertion, + "options": assertion, + "flow_token": flowToken, + "expires_at": expiresAt, }, }) } @@ -258,13 +346,29 @@ func PasskeyLoginFinish(c *gin.Context) { return } + request, err := parsePasskeyFinishRequest(c) + if err != nil { + common.ApiError(c, err) + return + } + parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential) + if err != nil { + common.ApiError(c, err) + return + } + wa, err := passkeysvc.BuildWebAuthn(c.Request) if err != nil { common.ApiError(c, err) return } - sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.LoginSessionKey) + sessionData, _, err := passkeysvc.PopSessionDataFlow( + request.FlowToken, + model.AuthFlowPurposePasskeyLogin, + 0, + "", + ) if err != nil { common.ApiError(c, err) return @@ -300,7 +404,7 @@ func PasskeyLoginFinish(c *gin.Context) { return passkeysvc.NewWebAuthnUser(user, credential), nil } - waUser, credential, err := wa.FinishPasskeyLogin(handler, *sessionData, c.Request) + waUser, credential, err := wa.ValidatePasskeyLogin(handler, *sessionData, parsedCredential) if err != nil { common.ApiError(c, err) return @@ -323,15 +427,7 @@ func PasskeyLoginFinish(c *gin.Context) { return } - // 更新凭证信息 - updatedCredential := model.NewPasskeyCredentialFromWebAuthn(modelUser.Id, credential) - if updatedCredential == nil { - common.ApiErrorMsg(c, "Passkey 凭证更新失败") - return - } - now := time.Now() - updatedCredential.LastUsedAt = &now - if err := model.UpsertPasskeyCredential(updatedCredential); err != nil { + if err := model.UpdatePasskeyAssertionState(modelUser.Id, credential, time.Now()); err != nil { common.ApiError(c, err) return } @@ -369,7 +465,11 @@ func AdminResetPasskey(c *gin.Context) { return } - if err := model.DeletePasskeyByUserID(user.Id); err != nil { + if err := model.DeletePasskeyByUserIDWithAuthVersion(user.Id); err != nil { + common.ApiError(c, err) + return + } + if _, err := model.RevokeAllUserSessions(user.Id, "admin_passkey_reset"); err != nil { common.ApiError(c, err) return } @@ -393,7 +493,7 @@ func PasskeyVerifyBegin(c *gin.Context) { return } - user, err := getSessionUser(c) + user, err := getAuthenticatedUser(c) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, @@ -401,6 +501,15 @@ func PasskeyVerifyBegin(c *gin.Context) { }) return } + var request passkeyVerifyBeginRequest + if err := common.DecodeJson(c.Request.Body, &request); err != nil { + common.ApiError(c, errors.New("无效的 Passkey 验证请求")) + return + } + if !isAllowedSecurityProofScope(request.Scope) { + common.ApiError(c, errors.New("不支持的安全验证范围")) + return + } credential, err := model.GetPasskeyByUserID(user.Id) if err != nil { @@ -424,7 +533,19 @@ func PasskeyVerifyBegin(c *gin.Context) { return } - if err := passkeysvc.SaveSessionData(c, passkeysvc.VerifySessionKey, sessionData); err != nil { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + return + } + flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow( + model.AuthFlowPurposePasskeyStepUp, + user.Id, + identity.SessionID, + request.Scope, + sessionData, + ) + if err != nil { common.ApiError(c, err) return } @@ -433,7 +554,9 @@ func PasskeyVerifyBegin(c *gin.Context) { "success": true, "message": "", "data": gin.H{ - "options": assertion, + "options": assertion, + "flow_token": flowToken, + "expires_at": expiresAt, }, }) } @@ -447,7 +570,7 @@ func PasskeyVerifyFinish(c *gin.Context) { return } - user, err := getSessionUser(c) + user, err := getAuthenticatedUser(c) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, @@ -456,6 +579,17 @@ func PasskeyVerifyFinish(c *gin.Context) { return } + request, err := parsePasskeyFinishRequest(c) + if err != nil { + common.ApiError(c, err) + return + } + parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential) + if err != nil { + common.ApiError(c, err) + return + } + wa, err := passkeysvc.BuildWebAuthn(c.Request) if err != nil { common.ApiError(c, err) @@ -471,53 +605,57 @@ func PasskeyVerifyFinish(c *gin.Context) { return } - sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey) + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + return + } + sessionData, scope, err := passkeysvc.PopSessionDataFlow( + request.FlowToken, + model.AuthFlowPurposePasskeyStepUp, + user.Id, + identity.SessionID, + ) if err != nil { common.ApiError(c, err) return } waUser := passkeysvc.NewWebAuthnUser(user, credential) - _, err = wa.FinishLogin(waUser, *sessionData, c.Request) + validatedCredential, err := wa.ValidateLogin(waUser, *sessionData, parsedCredential) if err != nil { common.ApiError(c, err) return } - // 更新凭证的最后使用时间 - now := time.Now() - credential.LastUsedAt = &now - if err := model.UpsertPasskeyCredential(credential); err != nil { + if err := model.UpdatePasskeyAssertionState(user.Id, validatedCredential, time.Now()); err != nil { common.ApiError(c, err) return } - session := sessions.Default(c) - // Mark passkey as ready; /api/verify will convert this into the final secure verification session. - session.Set(PasskeyReadySessionKey, time.Now().Unix()) - session.Delete(SecureVerificationSessionKey) - session.Delete(secureVerificationMethodSessionKey) - if err := session.Save(); err != nil { - common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err)) + proofToken, proofExpiresAt, err := service.IssueSecurityProof(identity, secureVerificationMethodPasskey, []string{scope}) + if err != nil { + common.ApiError(c, err) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Passkey 验证成功", + "data": gin.H{ + "proof_token": proofToken, + "expires_at": proofExpiresAt, + "method": secureVerificationMethodPasskey, + "scope": scope, + }, }) } -func getSessionUser(c *gin.Context) (*model.User, error) { - session := sessions.Default(c) - idRaw := session.Get("id") - if idRaw == nil { +func getAuthenticatedUser(c *gin.Context) (*model.User, error) { + id := c.GetInt("id") + if id == 0 { return nil, errors.New("未登录") } - id, ok := idRaw.(int) - if !ok { - return nil, errors.New("无效的会话信息") - } user := &model.User{Id: id} if err := user.FillUserById(); err != nil { return nil, err @@ -537,7 +675,7 @@ func requirePasskeyRegistrationVerification(c *gin.Context, userID int) bool { if twoFA == nil || !twoFA.IsEnabled { return true } - return requireSecureVerificationMethod(c, secureVerificationMethod2FA) + return middleware.RequireSecurityProof(c, securityProofScopePasskeyRegister, []string{secureVerificationMethod2FA}) } func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool { @@ -547,7 +685,7 @@ func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool { return false } if twoFA != nil && twoFA.IsEnabled { - return requireSecureVerificationMethod(c, secureVerificationMethod2FA) + return middleware.RequireSecurityProof(c, securityProofScopePasskeyDelete, []string{secureVerificationMethod2FA}) } _, err = model.GetPasskeyByUserID(userID) @@ -563,24 +701,5 @@ func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool { return false } - return requireSecureVerificationMethod(c, secureVerificationMethodPasskey) -} - -func requireSecureVerificationMethod(c *gin.Context, method string) bool { - session := sessions.Default(c) - verifiedAt, ok := session.Get(SecureVerificationSessionKey).(int64) - if !ok || time.Now().Unix()-verifiedAt >= SecureVerificationTimeout { - session.Delete(SecureVerificationSessionKey) - session.Delete(secureVerificationMethodSessionKey) - _ = session.Save() - common.ApiErrorMsg(c, "请先完成安全验证") - return false - } - - if verifiedMethod, ok := session.Get(secureVerificationMethodSessionKey).(string); !ok || verifiedMethod != method { - common.ApiErrorMsg(c, "请先完成对应的安全验证") - return false - } - - return true + return middleware.RequireSecurityProof(c, securityProofScopePasskeyDelete, []string{secureVerificationMethodPasskey}) } diff --git a/controller/passkey_test.go b/controller/passkey_test.go new file mode 100644 index 000000000000..84cdbf4ecb0f --- /dev/null +++ b/controller/passkey_test.go @@ -0,0 +1,130 @@ +package controller + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type passkeyTestBody struct { + *strings.Reader +} + +func (*passkeyTestBody) Close() error { return nil } + +func TestParsePasskeyFinishRequestDoesNotRewriteRequestBody(t *testing.T) { + gin.SetMode(gin.TestMode) + bodyText := `{"flow_token":"flow-1","credential":{"id":"credential-1"}}` + body := &passkeyTestBody{Reader: strings.NewReader(bodyText)} + request := httptest.NewRequest(http.MethodPost, "/api/user/passkey/register/finish", nil) + request.Body = body + request.ContentLength = int64(len(bodyText)) + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = request + + parsed, err := parsePasskeyFinishRequest(context) + require.NoError(t, err) + assert.Equal(t, "flow-1", parsed.FlowToken) + assert.JSONEq(t, `{"id":"credential-1"}`, string(parsed.Credential)) + assert.Same(t, body, context.Request.Body) + assert.Equal(t, int64(len(bodyText)), context.Request.ContentLength) +} + +func TestPasskeyRegisterFinishRejectsMissingOrWrongProofWithoutConsumingFlow(t *testing.T) { + previousDB := model.DB + previousType := common.MainDatabaseType() + previousRedis := common.RedisEnabled + previousSecret := common.SessionSecret + settings := system_setting.GetPasskeySettings() + previousSettings := *settings + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.TwoFA{}, &model.AuthFlow{})) + model.DB = db + common.SetMainDatabaseType(common.DatabaseTypeSQLite) + common.RedisEnabled = false + common.SessionSecret = "passkey-register-proof-test-secret" + *settings = system_setting.PasskeySettings{Enabled: true} + t.Cleanup(func() { + model.DB = previousDB + common.SetMainDatabaseType(previousType) + common.RedisEnabled = previousRedis + common.SessionSecret = previousSecret + *settings = previousSettings + sqlDB, dbErr := db.DB() + if dbErr == nil { + _ = sqlDB.Close() + } + }) + + user := &model.User{ + Username: "passkey-proof-user", Password: "password-placeholder", Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, + } + require.NoError(t, db.Create(user).Error) + require.NoError(t, db.Create(&model.TwoFA{UserId: user.Id, Secret: "totp-secret", IsEnabled: true}).Error) + identity := service.AuthIdentity{ + UserID: user.Id, SessionID: "passkey-proof-session", UserAuthVersion: 1, SessionVersion: 1, + } + wrongScopeProof, _, err := service.IssueSecurityProof(identity, secureVerificationMethod2FA, []string{securityProofScopePasskeyDelete}) + require.NoError(t, err) + + tests := []struct { + name string + proof string + expectedCode string + }{ + {name: "missing proof", expectedCode: "SECURITY_PROOF_REQUIRED"}, + {name: "wrong scope proof", proof: wrongScopeProof, expectedCode: "SECURITY_PROOF_SCOPE_MISMATCH"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposePasskeyRegister, UserId: user.Id, SessionId: identity.SessionID, + Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + body := fmt.Sprintf(`{"flow_token":%q,"credential":{}}`, flowToken) + request := httptest.NewRequest(http.MethodPost, "/api/user/passkey/register/finish", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + if test.proof != "" { + request.Header.Set("X-Security-Proof", test.proof) + } + response := httptest.NewRecorder() + context, _ := gin.CreateTestContext(response) + context.Request = request + context.Set("id", identity.UserID) + context.Set("session_id", identity.SessionID) + context.Set("auth_version", identity.UserAuthVersion) + context.Set("session_version", identity.SessionVersion) + + PasskeyRegisterFinish(context) + + assert.Equal(t, http.StatusForbidden, response.Code) + var responseBody struct { + Code string `json:"code"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &responseBody)) + assert.Equal(t, test.expectedCode, responseBody.Code) + flow, err := model.GetAuthFlow(flowToken, model.AuthFlowMatch{ + Purpose: model.AuthFlowPurposePasskeyRegister, UserId: user.Id, SessionId: identity.SessionID, + }) + require.NoError(t, err) + assert.Nil(t, flow.ConsumedAt) + }) + } +} diff --git a/controller/secure_verification.go b/controller/secure_verification.go index 7640269eb599..f4d4ba8299a6 100644 --- a/controller/secure_verification.go +++ b/controller/secure_verification.go @@ -1,179 +1,88 @@ package controller import ( + "errors" "fmt" "net/http" - "time" + "strings" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" - "github.com/gin-contrib/sessions" + "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" ) const ( - // SecureVerificationSessionKey means the user has fully passed secure verification. - SecureVerificationSessionKey = "secure_verified_at" - secureVerificationMethodSessionKey = "secure_verified_method" - secureVerificationMethod2FA = "2fa" - secureVerificationMethodPasskey = "passkey" - // PasskeyReadySessionKey means WebAuthn finished and /api/verify can finalize step-up verification. - PasskeyReadySessionKey = "secure_passkey_ready_at" - // SecureVerificationTimeout 验证有效期(秒) - SecureVerificationTimeout = 300 // 5分钟 - // PasskeyReadyTimeout passkey ready 标记有效期(秒) - PasskeyReadyTimeout = 60 + secureVerificationMethod2FA = "2fa" + secureVerificationMethodPasskey = "passkey" ) type UniversalVerifyRequest struct { - Method string `json:"method"` // "2fa" 或 "passkey" + Method string `json:"method"` Code string `json:"code,omitempty"` + Scope string `json:"scope"` } -type VerificationStatusResponse struct { - Verified bool `json:"verified"` - ExpiresAt int64 `json:"expires_at,omitempty"` -} - -// UniversalVerify 通用验证接口 -// 支持 2FA 和 Passkey 验证,验证成功后在 session 中记录时间戳 func UniversalVerify(c *gin.Context) { - userId := c.GetInt("id") - if userId == 0 { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": "未登录", - }) + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"}) return } - - var req UniversalVerifyRequest - if err := c.ShouldBindJSON(&req); err != nil { + var request UniversalVerifyRequest + if err := common.DecodeJson(c.Request.Body, &request); err != nil { common.ApiError(c, fmt.Errorf("参数错误: %v", err)) return } - - // 获取用户信息 - user := &model.User{Id: userId} - if err := user.FillUserById(); err != nil { - common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err)) + if request.Method != secureVerificationMethod2FA { + common.ApiError(c, errors.New("Passkey 验证必须使用 Passkey verify 流程")) return } - - if user.Status != common.UserStatusEnabled { - common.ApiError(c, fmt.Errorf("该用户已被禁用")) + if !isAllowedSecurityProofScope(request.Scope) { + common.ApiError(c, errors.New("不支持的安全验证范围")) return } - - // 检查用户的验证方式 - twoFA, _ := model.GetTwoFAByUserId(userId) - has2FA := twoFA != nil && twoFA.IsEnabled - - passkey, passkeyErr := model.GetPasskeyByUserID(userId) - hasPasskey := passkeyErr == nil && passkey != nil - - if !has2FA && !hasPasskey { - common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey")) + if strings.TrimSpace(request.Code) == "" { + common.ApiError(c, errors.New("验证码不能为空")) return } - - // 根据验证方式进行验证 - var verified bool - var verifyMethod string - var err error - - switch req.Method { - case "2fa": - if !has2FA { - common.ApiError(c, fmt.Errorf("用户未启用2FA")) - return - } - if req.Code == "" { - common.ApiError(c, fmt.Errorf("验证码不能为空")) - return - } - verified = validateTwoFactorAuth(twoFA, req.Code) - verifyMethod = "2FA" - - case "passkey": - if !hasPasskey { - common.ApiError(c, fmt.Errorf("用户未启用Passkey")) - return - } - // Passkey branch only trusts the short-lived marker written by PasskeyVerifyFinish. - verified, err = consumePasskeyReady(c) - if err != nil { - common.ApiError(c, fmt.Errorf("Passkey 验证状态异常: %v", err)) - return - } - if !verified { - common.ApiError(c, fmt.Errorf("请先完成 Passkey 验证")) - return - } - verifyMethod = "Passkey" - - default: - common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method)) + twoFA, err := model.GetTwoFAByUserId(identity.UserID) + if err != nil { + common.ApiError(c, err) return } - - if !verified { - common.ApiError(c, fmt.Errorf("验证失败,请检查验证码")) + if twoFA == nil || !twoFA.IsEnabled { + common.ApiError(c, errors.New("用户未启用2FA")) return } - - // 验证成功,在 session 中记录时间戳 - now, err := setSecureVerificationSession(c, req.Method) + if !validateTwoFactorAuth(twoFA, request.Code) { + common.ApiError(c, errors.New("验证失败,请检查验证码")) + return + } + proofToken, expiresAt, err := service.IssueSecurityProof(identity, request.Method, []string{request.Scope}) if err != nil { - common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err)) + common.ApiError(c, err) return } - - // 记录日志 - model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("通用安全验证成功 (验证方式: %s)", verifyMethod)) - + model.RecordLog(identity.UserID, model.LogTypeSystem, "通用安全验证成功 (验证方式: 2FA)") c.JSON(http.StatusOK, gin.H{ "success": true, "message": "验证成功", "data": gin.H{ - "verified": true, - "expires_at": now + SecureVerificationTimeout, + "proof_token": proofToken, + "expires_at": expiresAt, + "method": request.Method, + "scope": request.Scope, }, }) } -func setSecureVerificationSession(c *gin.Context, method string) (int64, error) { - session := sessions.Default(c) - session.Delete(PasskeyReadySessionKey) - now := time.Now().Unix() - session.Set(SecureVerificationSessionKey, now) - session.Set(secureVerificationMethodSessionKey, method) - if err := session.Save(); err != nil { - return 0, err - } - return now, nil -} - -func consumePasskeyReady(c *gin.Context) (bool, error) { - session := sessions.Default(c) - readyAtRaw := session.Get(PasskeyReadySessionKey) - if readyAtRaw == nil { - return false, nil - } - - readyAt, ok := readyAtRaw.(int64) - if !ok { - session.Delete(PasskeyReadySessionKey) - _ = session.Save() - return false, fmt.Errorf("无效的 Passkey 验证状态") - } - session.Delete(PasskeyReadySessionKey) - if err := session.Save(); err != nil { - return false, err - } - // Expired ready markers cannot be reused. - if time.Now().Unix()-readyAt >= PasskeyReadyTimeout { - return false, nil +func isAllowedSecurityProofScope(scope string) bool { + switch scope { + case securityProofScopeChannelKeyRead, securityProofScopePasskeyRegister, securityProofScopePasskeyDelete: + return true + default: + return false } - return true, nil } diff --git a/controller/telegram.go b/controller/telegram.go index d51bed2bb09c..13e522599bce 100644 --- a/controller/telegram.go +++ b/controller/telegram.go @@ -13,10 +13,12 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) const ( @@ -24,11 +26,54 @@ const ( // so captured callbacks cannot be reused indefinitely. telegramAuthorizationMaxAge = 5 * time.Minute telegramAuthorizationFutureSkew = 2 * time.Minute + telegramBindFlowTTL = 5 * time.Minute ) +var ( + errTelegramAccountAlreadyBound = errors.New("telegram account is already bound") + errTelegramBindUserDeleted = errors.New("telegram bind user was deleted") + errTelegramBindUserDisabled = errors.New("telegram bind user is disabled") +) + +func TelegramBindStart(c *gin.Context) { + if !common.TelegramOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "管理员未开启通过 Telegram 登录以及注册", + "success": false, + }) + return + } + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) + return + } + expiresAt := time.Now().Add(telegramBindFlowTTL) + flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeTelegramBind, + UserId: identity.UserID, + SessionId: identity.SessionID, + ExpiresAt: expiresAt, + }) + if err != nil { + common.ApiError(c, err) + return + } + callbackURL := "/api/oauth/telegram/bind/" + flowToken + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "flow_token": flowToken, + "callback_url": callbackURL, + "expires_at": expiresAt.Unix(), + }, + }) +} + func TelegramBind(c *gin.Context) { if !common.TelegramOAuthEnabled { - c.JSON(200, gin.H{ + c.JSON(http.StatusOK, gin.H{ "message": "管理员未开启通过 Telegram 登录以及注册", "success": false, }) @@ -44,41 +89,108 @@ func TelegramBind(c *gin.Context) { }) return } - if model.IsTelegramIdAlreadyTaken(telegramId) { - c.JSON(200, gin.H{ - "message": "该 Telegram 账户已被绑定", + pendingFlow, err := model.GetAuthFlow(c.Param("flow_token"), model.AuthFlowMatch{ + Purpose: model.AuthFlowPurposeTelegramBind, + }) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{ + "message": "绑定流程已过期或已使用", "success": false, }) return } - - session := sessions.Default(c) - id := session.Get("id") - user := model.User{Id: id.(int)} - if err := user.FillUserById(); err != nil { - c.JSON(200, gin.H{ - "message": err.Error(), + if _, err := service.ValidateSessionReference(pendingFlow.UserId, pendingFlow.SessionId); err != nil { + c.JSON(http.StatusForbidden, gin.H{ + "message": "创建绑定的登录会话已失效", "success": false, }) return } - if user.Id == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "用户已注销", - }) + assertion, assertionExpiresAt, err := telegramAuthorizationClaim(params, time.Now()) + if err != nil { + common.SysLog("TelegramBind authorization claim failed: " + err.Error()) + c.JSON(http.StatusForbidden, gin.H{"message": "无效的请求", "success": false}) return } - user.TelegramId = telegramId - if err := user.Update(false); err != nil { - c.JSON(200, gin.H{ - "message": err.Error(), - "success": false, - }) + _, err = model.ConsumeAuthFlowWithAction(c.Param("flow_token"), model.AuthFlowMatch{ + Purpose: model.AuthFlowPurposeTelegramBind, + UserId: pendingFlow.UserId, + SessionId: pendingFlow.SessionId, + }, func(tx *gorm.DB, flow *model.AuthFlow) error { + if err := model.ClaimExternalAuthAssertionWithTx(tx, model.AuthFlowPurposeTelegramAssertion, assertion, assertionExpiresAt); err != nil { + return err + } + + var session model.UserSession + if err := tx.Where("sid = ? AND user_id = ?", flow.SessionId, flow.UserId).First(&session).Error; err != nil { + return service.ErrLoginSessionRevoked + } + if session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= time.Now().Unix() { + return service.ErrLoginSessionRevoked + } + + var user model.User + if err := tx.First(&user, flow.UserId).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errTelegramBindUserDeleted + } + return err + } + if user.Status != common.UserStatusEnabled { + return errTelegramBindUserDisabled + } + if session.UserAuthVersion != user.AuthVersion { + return service.ErrLoginSessionRevoked + } + if user.TelegramId != "" { + return errTelegramAccountAlreadyBound + } + if err := model.ClaimExternalIdentityWithTx( + tx, + model.ExternalIdentityProviderTelegram, + telegramId, + user.Id, + ); err != nil { + if errors.Is(err, model.ErrExternalIdentityAlreadyClaimed) { + return errTelegramAccountAlreadyBound + } + return err + } + result := tx.Model(&model.User{}). + Where("id = ? AND status = ? AND auth_version = ? AND telegram_id = ?", user.Id, common.UserStatusEnabled, user.AuthVersion, ""). + Update("telegram_id", telegramId) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return errTelegramAccountAlreadyBound + } + return nil + }) + if err != nil { + switch { + case errors.Is(err, errTelegramAccountAlreadyBound): + c.JSON(http.StatusOK, gin.H{"message": "该 Telegram 账户已被绑定", "success": false}) + case errors.Is(err, errTelegramBindUserDeleted): + c.JSON(http.StatusOK, gin.H{"message": "用户已注销", "success": false}) + case errors.Is(err, errTelegramBindUserDisabled): + c.JSON(http.StatusForbidden, gin.H{"message": "用户已被禁用", "success": false}) + case errors.Is(err, service.ErrLoginSessionRevoked): + c.JSON(http.StatusForbidden, gin.H{"message": "创建绑定的登录会话已失效", "success": false}) + case errors.Is(err, model.ErrAuthFlowInvalid), errors.Is(err, model.ErrAuthFlowExpired), errors.Is(err, model.ErrAuthFlowConsumed): + c.JSON(http.StatusForbidden, gin.H{"message": "绑定流程已过期或已使用", "success": false}) + default: + common.ApiError(c, err) + } return } - c.Redirect(302, common.ThemeAwarePath("/console/personal")) + if common.GetTheme() == "default" { + callback := "/oauth/telegram?telegram_bind=success&flow_token=" + url.QueryEscape(c.Param("flow_token")) + c.Redirect(http.StatusFound, callback) + return + } + c.Redirect(http.StatusFound, "/console/personal") } func TelegramLogin(c *gin.Context) { @@ -108,9 +220,41 @@ func TelegramLogin(c *gin.Context) { }) return } + if err := claimTelegramAuthorization(params, time.Now()); err != nil { + common.SysLog("TelegramLogin assertion replay rejected: " + err.Error()) + c.JSON(http.StatusForbidden, gin.H{ + "message": "该登录凭据已被使用", + "success": false, + }) + return + } setupLogin(&user, c) } +func claimTelegramAuthorization(params url.Values, now time.Time) error { + assertion, expiresAt, err := telegramAuthorizationClaim(params, now) + if err != nil { + return err + } + return model.ClaimExternalAuthAssertion(model.AuthFlowPurposeTelegramAssertion, assertion, expiresAt) +} + +func telegramAuthorizationClaim(params url.Values, now time.Time) (string, time.Time, error) { + authDate, err := strconv.ParseInt(params.Get("auth_date"), 10, 64) + if err != nil { + return "", time.Time{}, errors.New("telegram authorization date is invalid") + } + hashBytes, err := hex.DecodeString(params.Get("hash")) + if err != nil { + return "", time.Time{}, errors.New("telegram authorization signature is invalid") + } + expiresAt := time.Unix(authDate, 0).Add(telegramAuthorizationMaxAge) + if !expiresAt.After(now) { + return "", time.Time{}, errors.New("telegram authorization has expired") + } + return hex.EncodeToString(hashBytes), expiresAt, nil +} + func verifyTelegramAuthorization(params url.Values, token string, now time.Time) (string, error) { if token == "" { return "", errors.New("telegram bot token is empty") diff --git a/controller/telegram_test.go b/controller/telegram_test.go index 5b683fb7ec37..01dde0fbc3ba 100644 --- a/controller/telegram_test.go +++ b/controller/telegram_test.go @@ -4,6 +4,8 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "net/http" + "net/http/httptest" "net/url" "sort" "strconv" @@ -11,8 +13,13 @@ import ( "testing" "time" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) func TestVerifyTelegramAuthorization(t *testing.T) { @@ -31,6 +38,7 @@ func TestVerifyTelegramAuthorization(t *testing.T) { {name: "expired", authDate: now.Add(-telegramAuthorizationMaxAge - time.Second), wantErr: "expired"}, {name: "too far in future", authDate: now.Add(telegramAuthorizationFutureSkew + time.Second), wantErr: "expired"}, {name: "invalid signature", authDate: now, mutate: func(values url.Values) { values.Set("hash", "00") }, wantErr: "signature"}, + {name: "unsigned flow token query is rejected", authDate: now, mutate: func(values url.Values) { values.Set("flow_token", "must-be-in-path") }, wantErr: "signature"}, {name: "duplicate parameter", authDate: now, mutate: func(values url.Values) { values["id"] = append(values["id"], "654321") }, wantErr: "duplicate"}, } @@ -61,8 +69,16 @@ func signedTelegramAuthorization(token string, authDate time.Time) url.Values { "first_name": {"Test"}, "id": {"123456"}, } + signTelegramAuthorization(token, params) + return params +} + +func signTelegramAuthorization(token string, params url.Values) { keys := make([]string, 0, len(params)) for key := range params { + if key == "hash" { + continue + } keys = append(keys, key) } sort.Strings(keys) @@ -74,5 +90,128 @@ func signedTelegramAuthorization(token string, authDate time.Time) url.Values { mac := hmac.New(sha256.New, secret[:]) _, _ = mac.Write([]byte(strings.Join(dataCheck, "\n"))) params.Set("hash", hex.EncodeToString(mac.Sum(nil))) - return params +} + +func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) { + previousDB := model.DB + previousType := common.MainDatabaseType() + previousRedis := common.RedisEnabled + previousEnabled := common.TelegramOAuthEnabled + previousToken := common.TelegramBotToken + previousSecret := common.SessionSecret + previousTheme := common.GetTheme() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.User{}, + &model.UserSession{}, + &model.AuthFlow{}, + &model.ExternalIdentityClaim{}, + )) + model.DB = db + common.SetMainDatabaseType(common.DatabaseTypeSQLite) + common.RedisEnabled = false + common.TelegramOAuthEnabled = true + common.TelegramBotToken = "telegram-bind-test-token" + common.SessionSecret = "telegram-bind-session-secret" + common.SetTheme("default") + t.Cleanup(func() { + model.DB = previousDB + common.SetMainDatabaseType(previousType) + common.RedisEnabled = previousRedis + common.TelegramOAuthEnabled = previousEnabled + common.TelegramBotToken = previousToken + common.SessionSecret = previousSecret + common.SetTheme(previousTheme) + }) + + user := &model.User{ + Username: "telegram-bind-user", Password: "password-placeholder", Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "telegram-bind-user", + } + require.NoError(t, db.Create(user).Error) + now := time.Now() + session := &model.UserSession{ + SID: "telegram-bind-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion, + Status: model.UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password", + CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), + } + require.NoError(t, model.CreateUserSession(session)) + flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID, + ExpiresAt: now.Add(time.Minute), + }) + require.NoError(t, err) + params := signedTelegramAuthorization(common.TelegramBotToken, now) + router := gin.New() + router.GET("/api/oauth/telegram/bind/:flow_token", TelegramBind) + request := httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+flowToken+"?"+params.Encode(), nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + assert.Equal(t, http.StatusFound, response.Code) + var storedUser model.User + require.NoError(t, db.First(&storedUser, user.Id).Error) + assert.Equal(t, "123456", storedUser.TelegramId) + var identityClaim model.ExternalIdentityClaim + require.NoError(t, db.Where("provider = ? AND subject = ?", model.ExternalIdentityProviderTelegram, "123456"). + First(&identityClaim).Error) + assert.Equal(t, user.Id, identityClaim.UserId) + _, err = model.GetAuthFlow(flowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) + assert.ErrorIs(t, err, model.ErrAuthFlowConsumed) + + replayFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID, + ExpiresAt: now.Add(time.Minute), + }) + require.NoError(t, err) + request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+replayFlowToken+"?"+params.Encode(), nil) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + assert.Equal(t, http.StatusForbidden, response.Code) + replayFlow, err := model.GetAuthFlow(replayFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) + require.NoError(t, err) + assert.Nil(t, replayFlow.ConsumedAt) + + competingUser := &model.User{ + Username: "telegram-bind-competing-user", Password: "password-placeholder", Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "telegram-bind-competing-user", + } + require.NoError(t, db.Create(competingUser).Error) + competingSession := &model.UserSession{ + SID: "telegram-bind-competing-session", UserID: competingUser.Id, Version: 1, + UserAuthVersion: competingUser.AuthVersion, Status: model.UserSessionStatusActive, + RefreshHash: "competing-refresh-hash", LoginMethod: "password", + CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), + } + require.NoError(t, model.CreateUserSession(competingSession)) + competingFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeTelegramBind, UserId: competingUser.Id, SessionId: competingSession.SID, + ExpiresAt: now.Add(time.Minute), + }) + require.NoError(t, err) + competingParams := signedTelegramAuthorization(common.TelegramBotToken, now) + competingParams.Set("first_name", "Competing") + signTelegramAuthorization(common.TelegramBotToken, competingParams) + request = httptest.NewRequest( + http.MethodGet, + "/api/oauth/telegram/bind/"+competingFlowToken+"?"+competingParams.Encode(), + nil, + ) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + assert.Equal(t, http.StatusOK, response.Code) + + require.NoError(t, db.First(competingUser, competingUser.Id).Error) + assert.Empty(t, competingUser.TelegramId) + competingFlow, err := model.GetAuthFlow(competingFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) + require.NoError(t, err) + assert.Nil(t, competingFlow.ConsumedAt) + competingAssertion, competingAssertionExpiry, err := telegramAuthorizationClaim(competingParams, time.Now()) + require.NoError(t, err) + require.NoError(t, model.ClaimExternalAuthAssertion( + model.AuthFlowPurposeTelegramAssertion, + competingAssertion, + competingAssertionExpiry, + )) } diff --git a/controller/twofa.go b/controller/twofa.go index ec9ef1a006ec..27aae855dcc2 100644 --- a/controller/twofa.go +++ b/controller/twofa.go @@ -6,9 +6,10 @@ import ( "strconv" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) @@ -19,7 +20,12 @@ type Setup2FARequest struct { // Verify2FARequest 验证2FA请求结构 type Verify2FARequest struct { - Code string `json:"code" binding:"required"` + Code string `json:"code" binding:"required"` + FlowToken string `json:"flow_token,omitempty"` +} + +type twoFALoginFlowPayload struct { + AuthVersion int64 `json:"auth_version"` } // Setup2FAResponse 设置2FA响应结构 @@ -49,7 +55,7 @@ func Setup2FA(c *gin.Context) { // 如果存在已禁用的2FA记录,先删除它 if existing != nil && !existing.IsEnabled { - if err := existing.Delete(); err != nil { + if err := existing.DeletePendingTwoFASetup(); err != nil { common.ApiError(c, err) return } @@ -95,22 +101,13 @@ func Setup2FA(c *gin.Context) { IsEnabled: false, } - if existing != nil { - // 更新现有记录 - twoFA.Id = existing.Id - err = twoFA.Update() - } else { - // 创建新记录 - err = twoFA.Create() - } - - if err != nil { + if err := twoFA.CreatePendingTwoFASetup(); err != nil { common.ApiError(c, err) return } // 创建备用码记录 - if err := model.CreateBackupCodes(userId, backupCodes); err != nil { + if err := model.CreatePendingTwoFASetupBackupCodes(userId, backupCodes); err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "保存备用码失败", @@ -185,8 +182,18 @@ func Enable2FA(c *gin.Context) { return } - // 启用2FA - if err := twoFA.Enable(); err != nil { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + return + } + // 启用2FA并原子推进用户鉴权版本 + if err := twoFA.EnableWithAuthVersion(); err != nil { + common.ApiError(c, err) + return + } + bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_enabled") + if err != nil { common.ApiError(c, err) return } @@ -197,6 +204,7 @@ func Enable2FA(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "两步验证启用成功", + "data": authRotationData(bundle), }) } @@ -257,8 +265,18 @@ func Disable2FA(c *gin.Context) { return } - // 禁用2FA - if err := model.DisableTwoFA(userId); err != nil { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + return + } + // 禁用2FA并原子推进用户鉴权版本 + if err := model.DisableTwoFAWithAuthVersion(userId); err != nil { + common.ApiError(c, err) + return + } + bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_disabled") + if err != nil { common.ApiError(c, err) return } @@ -269,6 +287,7 @@ func Disable2FA(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "两步验证已禁用", + "data": authRotationData(bundle), }) } @@ -372,8 +391,13 @@ func RegenerateBackupCodes(c *gin.Context) { return } - // 保存新的备用码 - if err := model.CreateBackupCodes(userId, backupCodes); err != nil { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + return + } + // 保存新的备用码并原子推进用户鉴权版本 + if err := model.ReplaceBackupCodesWithAuthVersion(userId, backupCodes); err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "保存备用码失败", @@ -381,16 +405,21 @@ func RegenerateBackupCodes(c *gin.Context) { common.SysLog("保存备用码失败: " + err.Error()) return } + bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_backup_codes_regenerated") + if err != nil { + common.ApiError(c, err) + return + } // 记录操作日志 model.RecordLog(userId, model.LogTypeSystem, "重新生成两步验证备用码") + data := authRotationData(bundle) + data["backup_codes"] = backupCodes c.JSON(http.StatusOK, gin.H{ "success": true, "message": "备用码重新生成成功", - "data": map[string]interface{}{ - "backup_codes": backupCodes, - }, + "data": data, }) } @@ -405,30 +434,35 @@ func Verify2FALogin(c *gin.Context) { return } - // 从会话中获取pending用户信息 - session := sessions.Default(c) - pendingUserId := session.Get("pending_user_id") - if pendingUserId == nil { + flow, err := model.GetAuthFlow(req.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFALogin}) + if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "会话已过期,请重新登录", }) return } - userId, ok := pendingUserId.(int) - if !ok { + // 获取用户信息 + user, err := model.GetUserById(flow.UserId, false) + if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "会话数据无效,请重新登录", + "message": "用户不存在", }) return } - // 获取用户信息 - user, err := model.GetUserById(userId, false) - if err != nil { + if user.Status != common.UserStatusEnabled { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "用户不存在", + "message": "用户已被禁用", + }) + return + } + var flowPayload twoFALoginFlowPayload + if err := common.UnmarshalJsonStr(flow.Payload, &flowPayload); err != nil || flowPayload.AuthVersion <= 0 || flowPayload.AuthVersion != user.AuthVersion { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "会话已过期,请重新登录", }) return } @@ -477,12 +511,18 @@ func Verify2FALogin(c *gin.Context) { return } - // 2FA验证成功,清理pending会话信息并完成登录 - session.Delete("pending_username") - session.Delete("pending_user_id") - session.Save() + if _, err := model.ConsumeAuthFlow(req.FlowToken, model.AuthFlowMatch{ + Purpose: model.AuthFlowPurposeTwoFALogin, + UserId: user.Id, + }); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "会话已过期,请重新登录", + }) + return + } - setupLogin(user, c) + setupLoginAtAuthVersion(user, flowPayload.AuthVersion, c) } // Admin2FAStats 管理员获取2FA统计信息 @@ -529,7 +569,7 @@ func AdminDisable2FA(c *gin.Context) { } // 禁用2FA - if err := model.DisableTwoFA(userId); err != nil { + if err := model.DisableTwoFAWithAuthVersion(userId); err != nil { if errors.Is(err, model.ErrTwoFANotEnabled) { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -540,6 +580,10 @@ func AdminDisable2FA(c *gin.Context) { common.ApiError(c, err) return } + if _, err := model.RevokeAllUserSessions(userId, "admin_twofa_disabled"); err != nil { + common.ApiError(c, err) + return + } recordManageAuditFor(c, userId, "user.2fa_disable", nil) diff --git a/controller/user.go b/controller/user.go index 466353a43447..7c53b770a2ba 100644 --- a/controller/user.go +++ b/controller/user.go @@ -1,7 +1,6 @@ package controller import ( - "encoding/json" "errors" "fmt" "net/http" @@ -9,11 +8,13 @@ import ( "strconv" "strings" "sync" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service/authz" @@ -22,7 +23,6 @@ import ( "github.com/QuantumNous/new-api/constant" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -43,7 +43,7 @@ func Login(c *gin.Context) { return } var loginRequest LoginRequest - err := json.NewDecoder(c.Request.Body).Decode(&loginRequest) + err := common.DecodeJson(c.Request.Body, &loginRequest) if err != nil { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return @@ -80,13 +80,20 @@ func Login(c *gin.Context) { return } if twoFAEnabled { - // 设置pending session,等待2FA验证 - session := sessions.Default(c) - session.Set("pending_username", user.Username) - session.Set("pending_user_id", user.Id) - err := session.Save() + expiresAt := time.Now().Add(5 * time.Minute) + payload, err := common.Marshal(twoFALoginFlowPayload{AuthVersion: user.AuthVersion}) if err != nil { - common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed) + common.ApiError(c, err) + return + } + flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeTwoFALogin, + UserId: user.Id, + Payload: string(payload), + ExpiresAt: expiresAt, + }) + if err != nil { + common.ApiError(c, err) return } @@ -95,6 +102,8 @@ func Login(c *gin.Context) { "success": true, "data": map[string]interface{}{ "require_2fa": true, + "flow_token": flowToken, + "expires_at": expiresAt.Unix(), }, }) return @@ -140,49 +149,57 @@ func recordLoginAudit(user *model.User, c *gin.Context) { }, extra) } -// setup session & cookies and then return user info +// setupLogin creates a server-controlled login Session and returns the shared +// authentication bundle used by every login method. func setupLogin(user *model.User, c *gin.Context) { - model.UpdateUserLastLoginAt(user.Id) - session := sessions.Default(c) - session.Set("id", user.Id) - session.Set("username", user.Username) - session.Set("role", user.Role) - session.Set("status", user.Status) - session.Set("group", user.Group) - err := session.Save() + setupLoginAtAuthVersion(user, 0, c) +} + +func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin.Context) { + if user == nil || user.Id <= 0 || user.Status != common.UserStatusEnabled { + common.ApiErrorI18n(c, i18n.MsgAuthUserBanned) + return + } + currentUser, err := model.GetUserById(user.Id, false) if err != nil { - common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed) + common.ApiError(c, err) return } - recordLoginAudit(user, c) - c.JSON(http.StatusOK, gin.H{ - "message": "", - "success": true, - "data": map[string]any{ - "id": user.Id, - "username": user.Username, - "display_name": user.DisplayName, - "role": user.Role, - "status": user.Status, - "group": user.Group, - }, - }) -} - -func Logout(c *gin.Context) { - session := sessions.Default(c) - session.Clear() - err := session.Save() + model.UpdateUserLastLoginAt(user.Id) + var bundle *service.AuthBundle + if expectedAuthVersion > 0 { + bundle, err = service.CreateLoginSessionAtAuthVersion( + user.Id, + expectedAuthVersion, + loginMethodFromContext(c), + c.ClientIP(), + c.Request.UserAgent(), + ) + } else { + bundle, err = service.CreateLoginSession( + user.Id, + loginMethodFromContext(c), + c.ClientIP(), + c.Request.UserAgent(), + ) + } if err != nil { - c.JSON(http.StatusOK, gin.H{ - "message": err.Error(), - "success": false, - }) + common.ApiError(c, err) return } + service.WriteRefreshCookie(c, bundle.RefreshToken) + setAuthNoStore(c) + recordLoginAudit(user, c) c.JSON(http.StatusOK, gin.H{ "message": "", "success": true, + "data": gin.H{ + "access_token": bundle.AccessToken, + "token_type": bundle.TokenType, + "access_expires_at": bundle.AccessExpiresAt, + "session": bundle.Session, + "user": buildSelfUserData(currentUser), + }, }) } @@ -196,7 +213,7 @@ func Register(c *gin.Context) { return } var user model.User - err := json.NewDecoder(c.Request.Body).Decode(&user) + err := common.DecodeJson(c.Request.Body, &user) if err != nil { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return @@ -476,18 +493,30 @@ func GetSelf(c *gin.Context) { common.ApiError(c, err) return } - // Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users - user.Remark = "" - - // 计算用户权限信息 + responseData := buildSelfUserData(user) + // The authenticated role is loaded from GetUserCache. It should equal the + // row role, but use it for capabilities so GetSelf and login/refresh remain + // consistent with the authorization decision made for this request. permissions := calculateUserPermissions(userRole) permissions["admin_permissions"] = authz.Capabilities(id, userRole) + responseData["permissions"] = permissions - // 获取用户设置并提取sidebar_modules - userSetting := user.GetSetting() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": responseData, + }) + return +} - // 构建响应数据,包含用户信息和权限 - responseData := map[string]interface{}{ +// buildSelfUserData is the single safe dashboard-user DTO used by GetSelf, +// login and refresh. It intentionally excludes password, management PAT and +// administrator-only remarks. +func buildSelfUserData(user *model.User) map[string]interface{} { + userSetting := user.GetSetting() + permissions := calculateUserPermissions(user.Role) + permissions["admin_permissions"] = authz.Capabilities(user.Id, user.Role) + return map[string]interface{}{ "id": user.Id, "username": user.Username, "display_name": user.DisplayName, @@ -512,15 +541,8 @@ func GetSelf(c *gin.Context) { "setting": user.Setting, "stripe_customer": user.StripeCustomer, "sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段 - "permissions": permissions, // 新增权限字段 + "permissions": permissions, } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": responseData, - }) - return } // 计算用户权限的辅助函数 @@ -604,7 +626,7 @@ func generateDefaultSidebarConfig(userRole int) string { // 普通用户不包含admin区域 // 转换为JSON字符串 - configBytes, err := json.Marshal(defaultConfig) + configBytes, err := common.Marshal(defaultConfig) if err != nil { common.SysLog("生成默认边栏配置失败: " + err.Error()) return "" @@ -661,7 +683,7 @@ func GetUserModels(c *gin.Context) { func UpdateUser(c *gin.Context) { var updatedUser model.User - err := json.NewDecoder(c.Request.Body).Decode(&updatedUser) + err := common.DecodeJson(c.Request.Body, &updatedUser) if err != nil || updatedUser.Id == 0 { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return @@ -715,8 +737,15 @@ func UpdateUser(c *gin.Context) { return } } - if err := model.InvalidateUserCache(updatedUser.Id); err != nil { - common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", updatedUser.Id, err.Error())) + if updatedUser.AuthVersion > originUser.AuthVersion { + if _, err := model.RevokeAllUserSessions(updatedUser.Id, "admin_user_update"); err != nil { + common.ApiError(c, err) + return + } + } + if err := model.PublishUserAuthCache(updatedUser.Id); err != nil { + common.ApiError(c, err) + return } recordManageAuditFor(c, updatedUser.Id, "user.update", map[string]interface{}{ "username": originUser.Username, @@ -872,15 +901,45 @@ func UpdateSelf(c *gin.Context) { common.ApiError(c, err) return } - if err := cleanUser.Update(updatePassword); err != nil { + if updatePassword { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + return + } + if err := model.DB.Transaction(func(tx *gorm.DB) error { + return cleanUser.UpdateWithTx(tx, true) + }); err != nil { + common.ApiError(c, err) + return + } + if err := model.PublishUserAuthCache(cleanUser.Id); err != nil { + common.ApiError(c, err) + return + } + bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "password_changed") + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "access_token": bundle.AccessToken, + "token_type": bundle.TokenType, + "access_expires_at": bundle.AccessExpiresAt, + "session": bundle.Session, + }, + }) + return + } + if err := cleanUser.Update(false); err != nil { common.ApiError(c, err) return } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - }) + c.JSON(http.StatusOK, gin.H{"success": true, "message": ""}) return } @@ -962,7 +1021,7 @@ func DeleteSelf(c *gin.Context) { func CreateUser(c *gin.Context) { var user model.User - err := json.NewDecoder(c.Request.Body).Decode(&user) + err := common.DecodeJson(c.Request.Body, &user) user.Username = strings.TrimSpace(user.Username) if err != nil || user.Username == "" || user.Password == "" { common.ApiErrorI18n(c, i18n.MsgInvalidParams) @@ -1044,7 +1103,7 @@ type ManageRequest struct { // ManageUser Only admin user can do this func ManageUser(c *gin.Context) { var req ManageRequest - err := json.NewDecoder(c.Request.Body).Decode(&req) + err := common.DecodeJson(c.Request.Body, &req) if err != nil { common.ApiErrorI18n(c, i18n.MsgInvalidParams) @@ -1090,6 +1149,16 @@ func ManageUser(c *gin.Context) { if err := model.InvalidateUserTokensCache(user.Id); err != nil { common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error())) } + recordManageAuditFor(c, user.Id, "user.manage", map[string]interface{}{ + "action": req.Action, + "username": user.Username, + "id": user.Id, + }) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return case "promote": if myRole != common.RoleRootUser { common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote) @@ -1155,25 +1224,32 @@ func ManageUser(c *gin.Context) { "message": "", }) return + default: + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return } - authzTouched := false if req.Action == "demote" { if err := model.DB.Transaction(func(tx *gorm.DB) error { if err := user.UpdateWithTx(tx, false); err != nil { return err } - authzTouched = true return authz.ClearUserAuthorizationInTx(tx, user.Id) }); err != nil { common.ApiError(c, err) return } - if authzTouched { - if err := authz.ReloadPolicy(); err != nil { - common.ApiError(c, err) - return - } + if err := authz.ReloadPolicy(); err != nil { + common.ApiError(c, err) + return + } + if err := model.PublishUserAuthCache(user.Id); err != nil { + common.ApiError(c, err) + return + } + if _, err := model.RevokeAllUserSessions(user.Id, "admin_demote"); err != nil { + common.ApiError(c, err) + return } } else { if err := user.Update(false); err != nil { @@ -1181,17 +1257,12 @@ func ManageUser(c *gin.Context) { return } } - // 禁用 / 角色调整后,强制失效用户缓存与其全部令牌缓存, - // 避免在 Redis TTL 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。 - // InvalidateUserCache 会让下一次 GetUserCache 从数据库重新加载, - // InvalidateUserTokensCache 则确保令牌侧的缓存也同步刷新。 - if req.Action == "disable" || req.Action == "promote" || req.Action == "demote" { - if err := model.InvalidateUserCache(user.Id); err != nil { - common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", user.Id, err.Error())) - } - if err := model.InvalidateUserTokensCache(user.Id); err != nil { - common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error())) - } + // Update/UpdateWithTx has already published the new user hash and revoked + // browser sessions exactly once. Only PAT/relay token caches still need an + // explicit invalidation; deleting the user hash here would discard the + // freshly published auth-version floor. + if err := model.InvalidateUserTokensCache(user.Id); err != nil { + common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error())) } recordManageAuditFor(c, user.Id, "user.manage", map[string]interface{}{ "action": req.Action, @@ -1228,10 +1299,12 @@ func EmailBind(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError) return } - session := sessions.Default(c) - id := session.Get("id") user := model.User{ - Id: id.(int), + Id: c.GetInt("id"), + } + if user.Id == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "not authenticated"}) + return } err := user.FillUserById() if err != nil { diff --git a/controller/user_manage_test.go b/controller/user_manage_test.go new file mode 100644 index 000000000000..1b52ece08835 --- /dev/null +++ b/controller/user_manage_test.go @@ -0,0 +1,161 @@ +package controller + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service/authz" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupManageUserTestDB(t *testing.T) *gorm.DB { + t.Helper() + previousDB, previousLogDB := model.DB, model.LOG_DB + previousRedisEnabled := common.RedisEnabled + previousMainDatabaseType, previousLogDatabaseType := common.MainDatabaseType(), common.LogDatabaseType() + common.RedisEnabled = false + common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite) + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + model.DB, model.LOG_DB = db, db + require.NoError(t, db.AutoMigrate( + &model.User{}, &model.UserSession{}, &model.Log{}, &model.CasbinRule{}, &model.AuthzRole{}, + )) + + t.Cleanup(func() { + model.DB, model.LOG_DB = previousDB, previousLogDB + common.RedisEnabled = previousRedisEnabled + common.SetDatabaseTypes(previousMainDatabaseType, previousLogDatabaseType) + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + return db +} + +func performManageUserRequest(t *testing.T, body string) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/user/manage", strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("id", 9999) + c.Set("role", common.RoleRootUser) + c.Set("username", "root-operator") + ManageUser(c) + return recorder +} + +func TestManageUserDisableAdvancesAuthVersionOnceAndRevokesSession(t *testing.T) { + db := setupManageUserTestDB(t) + now := time.Now().Unix() + user := model.User{ + Username: "managed-disable-user", Password: "password", Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, + } + require.NoError(t, db.Create(&user).Error) + require.NoError(t, db.Create(&model.UserSession{ + SID: "managed-disable-session", UserID: user.Id, Version: 1, UserAuthVersion: 1, + Status: model.UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password", + LastActiveAt: now, ExpiresAt: now + 3600, + }).Error) + + recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"disable"}`, user.Id)) + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Body.String(), `"success":true`) + + var updated model.User + require.NoError(t, db.First(&updated, user.Id).Error) + assert.Equal(t, common.UserStatusDisabled, updated.Status) + assert.EqualValues(t, 2, updated.AuthVersion) + var session model.UserSession + require.NoError(t, db.First(&session, "sid = ?", "managed-disable-session").Error) + assert.Equal(t, model.UserSessionStatusRevoked, session.Status) +} + +func TestManageUserDemoteAdvancesAuthVersionAndRevokesSessionsOnce(t *testing.T) { + db := setupManageUserTestDB(t) + previousMaster := common.IsMasterNode + common.IsMasterNode = false + t.Cleanup(func() { common.IsMasterNode = previousMaster }) + require.NoError(t, authz.Init(db)) + + now := time.Now().Unix() + user := model.User{ + Username: "managed-demote-user", Password: "password", Role: common.RoleAdminUser, + Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, + } + require.NoError(t, db.Create(&user).Error) + for _, sid := range []string{"managed-demote-session-one", "managed-demote-session-two"} { + require.NoError(t, db.Create(&model.UserSession{ + SID: sid, UserID: user.Id, Version: 1, UserAuthVersion: 1, + Status: model.UserSessionStatusActive, RefreshHash: "refresh-" + sid, LoginMethod: "password", + LastActiveAt: now, ExpiresAt: now + 3600, + }).Error) + } + + sessionUpdateCount := 0 + require.NoError(t, db.Callback().Update().Before("gorm:update").Register("test:count_demote_session_updates", func(tx *gorm.DB) { + if tx.Statement != nil && tx.Statement.Table == "user_sessions" { + sessionUpdateCount++ + } + })) + + recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"demote"}`, user.Id)) + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Body.String(), `"success":true`) + + var updated model.User + require.NoError(t, db.First(&updated, user.Id).Error) + assert.Equal(t, common.RoleCommonUser, updated.Role) + assert.EqualValues(t, 2, updated.AuthVersion) + var sessions []model.UserSession + require.NoError(t, db.Where("user_id = ?", user.Id).Order("sid asc").Find(&sessions).Error) + require.Len(t, sessions, 2) + for _, session := range sessions { + assert.Equal(t, model.UserSessionStatusRevoked, session.Status) + assert.Equal(t, "admin_demote", session.RevokedReason) + } + assert.Equal(t, 1, sessionUpdateCount) +} + +func TestManageUserDeleteReturnsImmediatelyAndUnknownActionFails(t *testing.T) { + db := setupManageUserTestDB(t) + deleted := model.User{ + Username: "managed-delete-user", Password: "password", Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "delete-aff", + } + require.NoError(t, db.Create(&deleted).Error) + + recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"delete"}`, deleted.Id)) + assert.Contains(t, recorder.Body.String(), `"success":true`) + var deletedCount int64 + require.NoError(t, db.Unscoped().Model(&model.User{}).Where("id = ? AND deleted_at IS NOT NULL", deleted.Id).Count(&deletedCount).Error) + assert.EqualValues(t, 1, deletedCount) + + unchanged := model.User{ + Username: "managed-unknown-user", Password: "password", Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "unknown-aff", + } + require.NoError(t, db.Create(&unchanged).Error) + recorder = performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"unknown"}`, unchanged.Id)) + assert.Contains(t, recorder.Body.String(), `"success":false`) + require.NoError(t, db.First(&unchanged, unchanged.Id).Error) + assert.EqualValues(t, 1, unchanged.AuthVersion) + assert.Equal(t, common.UserStatusEnabled, unchanged.Status) +} diff --git a/controller/wechat.go b/controller/wechat.go index 8889daca77db..dd185735e2df 100644 --- a/controller/wechat.go +++ b/controller/wechat.go @@ -1,7 +1,6 @@ package controller import ( - "encoding/json" "errors" "fmt" "net/http" @@ -12,7 +11,6 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) @@ -40,7 +38,7 @@ func getWeChatIdByCode(code string) (string, error) { } defer httpResponse.Body.Close() var res wechatLoginResponse - err = json.NewDecoder(httpResponse.Body).Decode(&res) + err = common.DecodeJson(httpResponse.Body, &res) if err != nil { return "", err } @@ -158,10 +156,12 @@ func WeChatBind(c *gin.Context) { }) return } - session := sessions.Default(c) - id := session.Get("id") user := model.User{ - Id: id.(int), + Id: c.GetInt("id"), + } + if user.Id == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) + return } err = user.FillUserById() if err != nil { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f98f4b0d8d1d..21a38a223208 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -31,7 +31,9 @@ services: - REDIS_CONN_STRING=redis://redis - TZ=Asia/Shanghai - BATCH_UPDATE_ENABLED=true - # Enable only when accessing the dev backend through HTTPS. SESSION_COOKIE_TRUSTED_URL is required when true. + # Local HTTP dev mode: keep Secure=false and leave TRUSTED_URL unset. This disables the refresh/logout OriginGuard so the :3001 -> :3000 dev proxy works. + - SESSION_COOKIE_SECURE=false + # For HTTPS only: set Secure=true and list every exact trusted HTTPS browser Origin. This does not configure relay CORS. # - SESSION_COOKIE_SECURE=true # - SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index f5881f4a24cc..b2f35620a26e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,8 +39,8 @@ services: # - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions) # - RELAY_IDLE_CONN_TIMEOUT=90 # Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制 (Relay HTTP client idle keep-alive timeout in seconds, defaults to Go standard library; set 0 to disable) # - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!) -# - SESSION_COOKIE_SECURE=true # 启用 Secure session cookie,必须同时配置 SESSION_COOKIE_TRUSTED_URL (Enable Secure session cookies; requires SESSION_COOKIE_TRUSTED_URL) -# - SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com # 可信 HTTPS 入口地址,多个用英文逗号分隔 (Trusted HTTPS entry URLs, comma-separated) +# - SESSION_COOKIE_SECURE=true # true:启用 Secure Refresh Cookie 和严格 refresh/logout OriginGuard;false/未配置:关闭 OriginGuard,仅用于本地 HTTP (true: Secure cookie + strict refresh/logout OriginGuard; false/unset: guard disabled for local HTTP only) +# - SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com # Secure=true 时必填的精确 HTTPS Origin;不是 relay CORS 白名单,不支持通配符/路径 (Required exact HTTPS origins when Secure=true; not a relay CORS allowlist, no wildcard/path) # - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed # - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID) # - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID) diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 000000000000..22db295a2b22 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,120 @@ +# 用户鉴权与登录会话 + +面板鉴权采用短期 Access Token、HttpOnly Refresh Cookie 与服务端登录会话控制面的组合。面板请求不再依赖 Gin session,也不再要求 `New-Api-User` 请求头。 + +## 鉴权模型 + +- Access Token 是有效期 15 分钟的 JWT,只保存在浏览器内存中,通过 `Authorization: Bearer ` 发送。 +- Refresh Token 是随机不透明值,有效期最长 30 天。浏览器只通过 `HttpOnly`、`SameSite=Strict` Cookie 持有它;服务端仅保存 HMAC 摘要,并在每次刷新时轮换。 +- `user_sessions` 是登录会话控制面,记录设备、IP、登录方式、最后活跃时间、到期时间和撤销状态。单个会话被撤销后,其 Access Token 会立即失效。 +- 用户的密码、状态、角色或安全因子发生安全相关变化时,`auth_version` 会递增并使旧登录会话失效。订阅带来的分组升降级只刷新授权缓存,不会退出任何登录设备。 +- Redis 缓存保存用户鉴权快照和登录会话快照。版本栅栏和撤销 tombstone 防止旧缓存重新授权;未启用 Redis 时回退到数据库校验。 + +`SESSION_SECRET` 用于派生 Access Token、Security Proof、Refresh Token 摘要和 AuthFlow 摘要的不同用途密钥。生产环境及多节点部署必须在所有节点配置相同的高强度随机值;更换该值会使现有登录、临时鉴权流程和 Security Proof 全部失效。 + +## 浏览器接口 + +登录成功后,密码登录、2FA、Passkey、OAuth、WeChat 和 Telegram 登录均返回统一数据: + +```json +{ + "success": true, + "data": { + "access_token": "...", + "token_type": "Bearer", + "access_expires_at": 1730000000, + "user": {}, + "session": { + "sid": "...", + "current": true, + "login_method": "password", + "ip": "...", + "user_agent": "...", + "created_at": 1730000000, + "last_active_at": 1730000000, + "expires_at": 1732592000 + } + } +} +``` + +会话相关接口: + +| 接口 | 鉴权 | 用途 | +| --- | --- | --- | +| `POST /api/user/auth/refresh` | Refresh Cookie;Secure 模式附加 Origin 校验 | 轮换 Refresh Token 并签发新的 Access Token | +| `POST /api/user/auth/logout` | Refresh Cookie;Secure 模式附加 Origin 校验,可同时携带 Bearer | 撤销当前登录会话并清除 Cookie | +| `GET /api/user/sessions` | Bearer | 查看当前用户的全部有效登录会话 | +| `DELETE /api/user/sessions/:sid` | Bearer | 撤销指定登录会话,包括当前会话 | +| `POST /api/user/sessions/revoke-others` | Bearer | 保留当前会话并撤销其他会话 | + +客户端内存中已有会话时,应在 refresh/logout 请求中发送 `X-Auth-Session: `。Refresh Cookie 与该 SID 不一致时,两个端点都返回 `409 AUTH_SESSION_MISMATCH`,且不会轮换、撤销或清除任何会话;客户端先通过 refresh 清除本标签页的旧 SID、恢复 Cookie 当前对应的会话,再重试 logout。冷启动尚无内存会话时可以省略该请求头。 + +并发使用同一个 Refresh Token 时,服务端通过确定性轮换恢复同一个后继 Token,多个浏览器标签页不会因丢失“胜者”响应而被迫退出。最近一代 Refresh Token 在短暂容错窗口结束后再次出现会撤销对应会话;无法识别的更早代或随机 Token 只会被拒绝,不会允许攻击者凭猜测踢掉会话。 + +前端使用 Web Locks 串行化同一浏览器配置文件中的刷新,并通过 BroadcastChannel(不支持时回退到 `storage` 事件)仅同步会话标识和登录/退出事件;Access Token 与 Refresh Token 都不会通过跨标签页消息传递或持久化到 Web Storage。 + +前端将冷启动状态与登录状态分开管理。网络或服务端临时故障允许后续导航重试 refresh;服务端确认 Refresh Cookie 无效时才进入已完成的匿名状态。内存 SID 与 Cookie SID 不一致时,客户端清除旧内存身份并在不携带旧 SID 的情况下重试一次。 + +## Refresh/Logout 的 Origin 校验 + +refresh/logout 的 Origin 防护与 Refresh Cookie 的 Secure 模式绑定: + +- 未配置 `SESSION_COOKIE_SECURE` 或显式设为 `false` 时,Refresh Cookie 可用于本地 HTTP,refresh/logout 的 OriginGuard 关闭,并且不得配置 `SESSION_COOKIE_TRUSTED_URL`。这使 `http://localhost` 上不同端口的 Rsbuild/Vite 开发代理可以正常转发请求。该模式仅用于可信的本地开发环境,不应暴露到公网。 +- `SESSION_COOKIE_SECURE=true` 时,Refresh Cookie 仅通过 HTTPS 发送,同时启用严格 OriginGuard。`POST /api/user/auth/refresh` 和 `POST /api/user/auth/logout` 会校验浏览器的 `Origin`;缺少 `Origin` 时只接受合法的单一 `Referer` 作为回退。允许来源包括请求自身的精确 Origin,以及 `SESSION_COOKIE_TRUSTED_URL` 中配置的精确 Origin。 + +Secure 模式的 Origin 校验不信任客户端直接发送的 `X-Forwarded-Proto`。TLS 在反向代理终止时,应将面板的公开 HTTPS Origin 明确写入 `SESSION_COOKIE_TRUSTED_URL`。 + +`SESSION_COOKIE_TRUSTED_URL` 现在具有明确的新语义:它是 refresh/logout Cookie 端点的可信 Origin 列表,不是 CORS 白名单。配置规则如下: + +- 仅在 `SESSION_COOKIE_SECURE=true` 时配置;多个值用英文逗号分隔。 +- 每项必须是精确的 HTTPS Origin,例如 `https://panel.example.com` 或 `https://panel.example.com:8443`。 +- 不接受通配符、路径、查询参数、用户信息或域名后缀匹配。 +- 不会修改 relay、旧 billing dashboard、`/api/usage/token` 或 `/api/log/token` 的 CORS 行为。浏览器使用 `sk-` key 直连 relay 的场景保持不变。 + +本地 HTTP 开发示例(OriginGuard 关闭): + +```env +SESSION_SECRET= +SESSION_COOKIE_SECURE=false +# SESSION_COOKIE_TRUSTED_URL 不得设置 +``` + +生产 HTTPS 示例(OriginGuard 开启): + +```env +SESSION_SECRET= +SESSION_COOKIE_SECURE=true +SESSION_COOKIE_TRUSTED_URL=https://panel.example.com,https://admin.example.com +``` + +该开关只控制面板 Refresh Cookie 和 refresh/logout 的 OriginGuard,不会修改 relay、旧 billing dashboard、`/api/usage/token` 或 `/api/log/token` 的 CORS 行为。 + +## PAT 调用契约 + +`User.AccessToken`(面板 PAT)继续支持 `Authorization: Bearer `,也兼容原有的单值 `Authorization: `。`New-Api-User` 不再参与鉴权,外部脚本不需要再发送 Bearer 与用户 ID 双请求头。这是有意的调用契约简化;旧 PAT 本身无需重新生成。 + +PAT 不是浏览器登录会话,不能调用登录会话管理接口,也不能签发绑定具体登录会话的 Security Proof。 + +## 临时鉴权流程与二次验证 + +OAuth state、2FA pending、Passkey ceremony、Telegram bind 等临时状态存放在 `auth_flows`。客户端只持有随机 `flow_token`,数据库仅保存 HMAC 摘要;流程具有用途、provider、intent、用户和登录会话绑定,并且只能原子消费一次。OAuth 注册的 affiliate code 也随登录 AuthFlow 保存。 + +标准 OAuth 绑定回调由 popup 通过同源 `postMessage` 交给 opener;只有 opener 使用自身内存中的 Bearer 调用后端绑定接口。Telegram 绑定先由已登录前端创建绑定 AuthFlow,再让 widget 回调携带路径中的 `flow_token`,回调时会重新确认原登录会话仍有效。Telegram 的已签名 widget assertion 也会登记为一次性凭据,重复回放会被拒绝。 + +敏感操作使用有效期 5 分钟的 `X-Security-Proof`: + +- `channel.key.read`:查看渠道密钥; +- `passkey.register`:注册 Passkey; +- `passkey.delete`:删除 Passkey。 + +Proof 同时绑定用户、登录会话、用户鉴权版本、会话版本和 scope,不能跨用户、跨会话或跨用途复用。 + +启用了 2FA 的用户注册 Passkey 时,register begin 与 finish 都必须携带有效的 `passkey.register` Proof;finish 会在消费一次性 AuthFlow 之前重新验证 Proof。未启用 2FA 的首次 Passkey 注册不要求该请求头。 + +## 升级注意事项 + +- 旧 `session` Cookie 不再使用;升级后现有面板登录会失效,用户需要重新登录。 +- 数据库迁移会新增 `user_sessions`、`auth_flows`、`external_identity_claims` 和 `users.auth_version`,并为已有用户初始化鉴权版本、回填 Telegram 账号唯一归属;若历史数据中同一 Telegram ID 已绑定多个用户,迁移会拒绝继续启动,需先消除歧义。 +- 仅 master 节点定时清理过期登录会话和已过保留期的 AuthFlow。 +- 自建客户端应按新的 AuthBundle、`flow_token` 和 Security Proof 契约升级;PAT 客户端可直接移除 `New-Api-User`。 diff --git a/docs/openapi/api.json b/docs/openapi/api.json index 493c61e4899c..3de3f8624622 100644 --- a/docs/openapi/api.json +++ b/docs/openapi/api.json @@ -624,6 +624,10 @@ "properties": { "code": { "type": "string" + }, + "flow_token": { + "type": "string", + "description": "密码校验成功后返回的一次性 2FA 登录流程令牌" } } } @@ -646,15 +650,107 @@ ] } }, - "/api/user/logout": { + "/api/user/auth/refresh": { + "post": { + "summary": "刷新面板登录", + "deprecated": false, + "description": "使用 HttpOnly Refresh Cookie 轮换刷新令牌并返回新的 AuthBundle;SESSION_COOKIE_SECURE=true 时要求同源或可信 Origin,false/未配置时保持本地 HTTP 兼容模式。已有内存会话时通过 X-Auth-Session 绑定预期 SID,Cookie 不匹配返回 409 AUTH_SESSION_MISMATCH 且不修改 Cookie。", + "tags": [ + "用户登陆注册" + ], + "parameters": [ + { + "name": "X-Auth-Session", + "in": "header", + "description": "可选的预期登录会话 SID;冷启动时可省略", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "headers": {} + }, + "409": { + "description": "X-Auth-Session 与 Refresh Cookie 的 SID 不一致;不修改 Cookie 或会话", + "headers": {} + } + }, + "security": [] + } + }, + "/api/user/auth/logout": { + "post": { + "summary": "撤销当前登录会话", + "deprecated": false, + "description": "撤销明确的 Bearer/X-Auth-Session 会话;仅当 HttpOnly Refresh Cookie 属于同一 SID 时才撤销并清除 Cookie,避免跨标签页误踢其他账号。SESSION_COOKIE_SECURE=true 时要求同源或可信 Origin,false/未配置时保持本地 HTTP 兼容模式。", + "tags": [ + "用户登陆注册" + ], + "parameters": [ + { + "name": "X-Auth-Session", + "in": "header", + "description": "可选的预期登录会话 SID", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "headers": {} + } + }, + "security": [] + } + }, + "/api/user/sessions": { "get": { - "summary": "用户登出", + "summary": "查看登录会话", "deprecated": false, - "description": "🔓 无需鉴权", + "description": "🔐 需要面板 Access Token;PAT 不能管理浏览器登录会话", "tags": [ "用户登陆注册" ], "parameters": [], + "responses": { + "200": { + "description": "返回当前用户的有效登录会话", + "headers": {} + } + }, + "security": [ + { + "AccessToken1": [] + } + ] + } + }, + "/api/user/sessions/{sid}": { + "delete": { + "summary": "撤销指定登录会话", + "deprecated": false, + "description": "🔐 需要面板 Access Token,可撤销当前会话", + "tags": [ + "用户登陆注册" + ], + "parameters": [ + { + "name": "sid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], "responses": { "200": { "description": "成功", @@ -663,10 +759,29 @@ }, "security": [ { - "Combination343": [] - }, + "AccessToken1": [] + } + ] + } + }, + "/api/user/sessions/revoke-others": { + "post": { + "summary": "撤销其他登录会话", + "deprecated": false, + "description": "🔐 保留当前登录会话并撤销该用户的其他会话", + "tags": [ + "用户登陆注册" + ], + "parameters": [], + "responses": { + "200": { + "description": "成功", + "headers": {} + } + }, + "security": [ { - "Combination1243": [] + "AccessToken1": [] } ] } @@ -867,14 +982,44 @@ } }, "/api/oauth/state": { - "get": { + "post": { "summary": "生成OAuth State", "deprecated": false, - "description": "🔓 无需鉴权", + "description": "创建一次性 OAuth AuthFlow。login 无需鉴权;bind 需要面板 Access Token 并绑定当前登录会话", "tags": [ "OAuth" ], "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "provider", + "intent" + ], + "properties": { + "provider": { + "type": "string" + }, + "intent": { + "type": "string", + "enum": [ + "login", + "bind" + ] + }, + "aff": { + "type": "string", + "description": "仅 login intent 可用的 affiliate code" + } + } + } + } + } + }, "responses": { "200": { "description": "成功", @@ -917,14 +1062,32 @@ } }, "/api/oauth/wechat/bind": { - "get": { + "post": { "summary": "绑定微信", "deprecated": false, - "description": "🔓 无需鉴权", + "description": "🔐 需要面板 Access Token", "tags": [ "OAuth" ], "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code" + ], + "properties": { + "code": { + "type": "string" + } + } + } + } + } + }, "responses": { "200": { "description": "成功", @@ -933,42 +1096,43 @@ }, "security": [ { - "Combination343": [] - }, - { - "Combination1243": [] + "AccessToken1": [] } ] } }, "/api/oauth/email/bind": { - "get": { + "post": { "summary": "绑定邮箱", "deprecated": false, - "description": "🔓 无需鉴权", + "description": "🔐 需要面板 Access Token", "tags": [ "OAuth" ], - "parameters": [ - { - "name": "email", - "in": "query", - "description": "", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "code", - "in": "query", - "description": "", - "required": false, - "schema": { - "type": "string" + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "code" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "code": { + "type": "string" + } + } + } } } - ], + }, "responses": { "200": { "description": "成功", @@ -977,10 +1141,7 @@ }, "security": [ { - "Combination343": [] - }, - { - "Combination1243": [] + "AccessToken1": [] } ] } @@ -1010,11 +1171,11 @@ ] } }, - "/api/oauth/telegram/bind": { - "get": { - "summary": "绑定Telegram", + "/api/oauth/telegram/bind/start": { + "post": { + "summary": "创建 Telegram 绑定流程", "deprecated": false, - "description": "🔓 无需鉴权", + "description": "🔐 需要面板 Access Token;返回绑定 widget 使用的一次性 flow_token 与 callback_url", "tags": [ "OAuth" ], @@ -1027,14 +1188,38 @@ }, "security": [ { - "Combination343": [] - }, - { - "Combination1243": [] + "AccessToken1": [] } ] } }, + "/api/oauth/telegram/bind/{flow_token}": { + "get": { + "summary": "完成 Telegram 绑定", + "deprecated": false, + "description": "Telegram widget 回调;通过一次性 flow_token 关联并重新校验创建该流程的登录会话", + "tags": [ + "OAuth" + ], + "parameters": [ + { + "name": "flow_token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "绑定成功后重定向到个人设置", + "headers": {} + } + }, + "security": [] + } + }, "/api/user/self/groups": { "get": { "summary": "获取当前用户分组", @@ -5147,27 +5332,36 @@ "type": "apiKey", "in": "cookie", "name": "session", - "description": "Session认证,通过登录接口获取" + "description": "已废弃:Gin session 不再用于面板鉴权" }, "AccessToken1": { - "type": "apiKey", - "in": "header", - "name": "Authorization", - "description": "Access Token认证,格式: Bearer {access_token},通过 /api/user/token 接口生成" + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT or PAT", + "description": "面板短期 JWT 或 User.AccessToken PAT;不再要求 New-Api-User" }, "NewApiUser1": { "type": "apiKey", "in": "header", "name": "New-Api-User", - "description": "用户ID请求头,必须与当前登录用户ID匹配,使用Session或AccessToken认证时必须提供" + "description": "已废弃且不再参与鉴权;PAT 客户端可以移除此请求头" + }, + "RefreshCookie": { + "type": "apiKey", + "in": "cookie", + "name": "new_api_refresh", + "description": "仅 refresh/logout 使用的 HttpOnly、SameSite=Strict Cookie" + }, + "SecurityProof": { + "type": "apiKey", + "in": "header", + "name": "X-Security-Proof", + "description": "绑定用户、登录会话和 scope 的短期二次验证 Proof" }, "Combination222": { "group": [ { - "id": 573666 - }, - { - "id": 573668 + "id": 573667 } ], "type": "combination" @@ -5176,9 +5370,6 @@ "group": [ { "id": 573667 - }, - { - "id": 573668 } ], "type": "combination" @@ -5186,10 +5377,7 @@ "Combination223": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5198,9 +5386,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5208,10 +5393,7 @@ "Combination224": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5220,9 +5402,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5230,10 +5409,7 @@ "Combination225": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5242,9 +5418,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5252,10 +5425,7 @@ "Combination226": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5264,9 +5434,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5274,10 +5441,7 @@ "Combination227": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5286,9 +5450,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5296,10 +5457,7 @@ "Combination228": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5308,9 +5466,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5318,10 +5473,7 @@ "Combination229": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5330,9 +5482,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5340,10 +5489,7 @@ "Combination230": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5352,9 +5498,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5362,10 +5505,7 @@ "Combination231": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5374,9 +5514,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5384,10 +5521,7 @@ "Combination232": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5396,9 +5530,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5406,10 +5537,7 @@ "Combination233": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5418,9 +5546,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5428,10 +5553,7 @@ "Combination234": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5440,9 +5562,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5450,10 +5569,7 @@ "Combination235": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5462,9 +5578,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5472,10 +5585,7 @@ "Combination236": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5484,9 +5594,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5494,10 +5601,7 @@ "Combination237": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5506,9 +5610,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5516,10 +5617,7 @@ "Combination238": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5528,9 +5626,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5538,10 +5633,7 @@ "Combination239": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5550,9 +5642,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5560,10 +5649,7 @@ "Combination240": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5572,9 +5658,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5582,10 +5665,7 @@ "Combination241": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5594,9 +5674,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5604,10 +5681,7 @@ "Combination242": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5616,9 +5690,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5626,10 +5697,7 @@ "Combination243": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5638,9 +5706,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5648,10 +5713,7 @@ "Combination244": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5660,9 +5722,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5670,10 +5729,7 @@ "Combination245": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5682,9 +5738,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5692,10 +5745,7 @@ "Combination246": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5704,9 +5754,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5714,10 +5761,7 @@ "Combination247": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5726,9 +5770,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5736,10 +5777,7 @@ "Combination248": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5748,9 +5786,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5758,10 +5793,7 @@ "Combination249": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5770,9 +5802,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5780,10 +5809,7 @@ "Combination250": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5792,9 +5818,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5802,10 +5825,7 @@ "Combination251": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5814,9 +5834,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5824,10 +5841,7 @@ "Combination252": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5836,9 +5850,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5846,10 +5857,7 @@ "Combination253": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5858,9 +5866,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5868,10 +5873,7 @@ "Combination254": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5880,9 +5882,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5890,10 +5889,7 @@ "Combination255": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5902,9 +5898,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5912,10 +5905,7 @@ "Combination256": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5924,9 +5914,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5934,10 +5921,7 @@ "Combination257": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5946,9 +5930,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5956,10 +5937,7 @@ "Combination258": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5968,9 +5946,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5978,10 +5953,7 @@ "Combination259": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5990,9 +5962,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6000,10 +5969,7 @@ "Combination260": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6012,9 +5978,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6022,10 +5985,7 @@ "Combination261": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6034,9 +5994,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6044,10 +6001,7 @@ "Combination262": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6056,9 +6010,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6066,10 +6017,7 @@ "Combination263": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6078,9 +6026,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6088,10 +6033,7 @@ "Combination264": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6100,9 +6042,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6110,10 +6049,7 @@ "Combination265": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6122,9 +6058,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6132,10 +6065,7 @@ "Combination266": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6144,9 +6074,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6154,10 +6081,7 @@ "Combination267": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6166,9 +6090,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6176,10 +6097,7 @@ "Combination268": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6188,9 +6106,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6198,10 +6113,7 @@ "Combination269": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6210,9 +6122,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6220,10 +6129,7 @@ "Combination270": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6232,9 +6138,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6242,10 +6145,7 @@ "Combination271": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6254,9 +6154,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6264,10 +6161,7 @@ "Combination272": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6276,9 +6170,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6286,10 +6177,7 @@ "Combination273": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6298,9 +6186,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6308,10 +6193,7 @@ "Combination274": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6320,9 +6202,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6330,10 +6209,7 @@ "Combination275": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6342,9 +6218,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6352,10 +6225,7 @@ "Combination276": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6364,9 +6234,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6374,10 +6241,7 @@ "Combination277": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6386,9 +6250,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6396,10 +6257,7 @@ "Combination278": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6408,9 +6266,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6418,10 +6273,7 @@ "Combination279": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6430,9 +6282,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6440,10 +6289,7 @@ "Combination280": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6452,9 +6298,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6462,10 +6305,7 @@ "Combination281": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6474,9 +6314,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6484,10 +6321,7 @@ "Combination282": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6496,9 +6330,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6506,10 +6337,7 @@ "Combination283": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6518,9 +6346,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6528,10 +6353,7 @@ "Combination284": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6540,9 +6362,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6550,10 +6369,7 @@ "Combination285": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6562,9 +6378,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6572,10 +6385,7 @@ "Combination286": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6584,9 +6394,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6594,10 +6401,7 @@ "Combination287": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6606,9 +6410,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6616,10 +6417,7 @@ "Combination288": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6628,9 +6426,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6638,10 +6433,7 @@ "Combination289": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6650,9 +6442,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6660,10 +6449,7 @@ "Combination290": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6672,9 +6458,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6682,10 +6465,7 @@ "Combination291": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6694,9 +6474,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6704,10 +6481,7 @@ "Combination292": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6716,9 +6490,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6726,10 +6497,7 @@ "Combination293": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6738,9 +6506,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6748,10 +6513,7 @@ "Combination294": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6760,9 +6522,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6770,10 +6529,7 @@ "Combination295": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6782,9 +6538,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6792,10 +6545,7 @@ "Combination296": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6804,9 +6554,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6814,10 +6561,7 @@ "Combination297": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6826,9 +6570,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6836,10 +6577,7 @@ "Combination298": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6848,9 +6586,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6858,10 +6593,7 @@ "Combination299": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6870,9 +6602,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6880,10 +6609,7 @@ "Combination300": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6892,9 +6618,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6902,10 +6625,7 @@ "Combination301": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6914,9 +6634,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6924,10 +6641,7 @@ "Combination302": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6936,9 +6650,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6946,10 +6657,7 @@ "Combination303": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6958,9 +6666,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6968,10 +6673,7 @@ "Combination304": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6980,9 +6682,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6990,10 +6689,7 @@ "Combination305": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7002,9 +6698,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7012,10 +6705,7 @@ "Combination306": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7024,9 +6714,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7034,10 +6721,7 @@ "Combination307": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7046,9 +6730,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7056,10 +6737,7 @@ "Combination308": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7068,9 +6746,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7078,10 +6753,7 @@ "Combination309": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7090,9 +6762,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7100,10 +6769,7 @@ "Combination310": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7112,9 +6778,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7122,10 +6785,7 @@ "Combination311": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7134,9 +6794,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7144,10 +6801,7 @@ "Combination312": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7156,9 +6810,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7166,10 +6817,7 @@ "Combination313": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7178,9 +6826,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7188,10 +6833,7 @@ "Combination314": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7200,9 +6842,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7210,10 +6849,7 @@ "Combination315": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7222,9 +6858,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7232,10 +6865,7 @@ "Combination316": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7244,9 +6874,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7254,10 +6881,7 @@ "Combination317": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7266,9 +6890,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7276,10 +6897,7 @@ "Combination318": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7288,9 +6906,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7298,10 +6913,7 @@ "Combination319": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7310,9 +6922,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7320,10 +6929,7 @@ "Combination320": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7332,9 +6938,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7342,10 +6945,7 @@ "Combination321": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7354,9 +6954,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7364,10 +6961,7 @@ "Combination322": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7376,9 +6970,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7386,10 +6977,7 @@ "Combination323": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7398,9 +6986,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7408,10 +6993,7 @@ "Combination324": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7420,9 +7002,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7430,10 +7009,7 @@ "Combination325": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7442,9 +7018,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7452,10 +7025,7 @@ "Combination326": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7464,9 +7034,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7474,10 +7041,7 @@ "Combination327": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7486,9 +7050,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7496,10 +7057,7 @@ "Combination328": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7508,9 +7066,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7518,10 +7073,7 @@ "Combination329": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7530,9 +7082,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7540,10 +7089,7 @@ "Combination330": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7552,9 +7098,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7562,10 +7105,7 @@ "Combination331": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7574,9 +7114,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7584,10 +7121,7 @@ "Combination332": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7596,9 +7130,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7606,10 +7137,7 @@ "Combination333": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7618,9 +7146,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7628,10 +7153,7 @@ "Combination334": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7640,9 +7162,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7650,10 +7169,7 @@ "Combination335": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7662,9 +7178,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7672,10 +7185,7 @@ "Combination336": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7684,9 +7194,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7694,10 +7201,7 @@ "Combination337": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7706,9 +7210,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7716,10 +7217,7 @@ "Combination338": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7728,9 +7226,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7738,10 +7233,7 @@ "Combination339": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7750,9 +7242,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7760,10 +7249,7 @@ "Combination340": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7772,9 +7258,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7782,10 +7265,7 @@ "Combination341": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7794,9 +7274,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7804,10 +7281,7 @@ "Combination342": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7816,9 +7290,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" diff --git a/docs/openapi/relay.json b/docs/openapi/relay.json index 62a0b65b082d..92a6239074e2 100644 --- a/docs/openapi/relay.json +++ b/docs/openapi/relay.json @@ -4607,27 +4607,24 @@ "type": "apiKey", "in": "cookie", "name": "session", - "description": "Session认证,通过登录接口获取" + "description": "已废弃:Gin session 不再用于面板鉴权" }, "AccessToken": { - "type": "apiKey", - "in": "header", - "name": "Authorization", - "description": "Access Token认证,格式: Bearer {access_token},通过 /api/user/token 接口生成" + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT or PAT", + "description": "面板短期 JWT 或 User.AccessToken PAT;不再要求 New-Api-User" }, "NewApiUser": { "type": "apiKey", "in": "header", "name": "New-Api-User", - "description": "用户ID请求头,必须与当前登录用户ID匹配,使用Session或AccessToken认证时必须提供" + "description": "已废弃且不再参与鉴权;PAT 客户端可以移除此请求头" }, "Combination": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4636,9 +4633,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4646,10 +4640,7 @@ "Combination2": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4658,9 +4649,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4668,10 +4656,7 @@ "Combination3": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4680,9 +4665,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4690,10 +4672,7 @@ "Combination4": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4702,9 +4681,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4712,10 +4688,7 @@ "Combination5": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4724,9 +4697,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4734,10 +4704,7 @@ "Combination6": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4746,9 +4713,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4756,10 +4720,7 @@ "Combination7": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4768,9 +4729,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4778,10 +4736,7 @@ "Combination8": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4790,9 +4745,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4800,10 +4752,7 @@ "Combination9": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4812,9 +4761,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4822,10 +4768,7 @@ "Combination10": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4834,9 +4777,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4844,10 +4784,7 @@ "Combination20": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4856,9 +4793,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4866,10 +4800,7 @@ "Combination21": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4878,9 +4809,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4888,10 +4816,7 @@ "Combination22": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4900,9 +4825,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4910,10 +4832,7 @@ "Combination23": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4922,9 +4841,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4932,10 +4848,7 @@ "Combination24": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4944,9 +4857,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4954,10 +4864,7 @@ "Combination25": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4966,9 +4873,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4976,10 +4880,7 @@ "Combination26": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -4988,9 +4889,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -4998,10 +4896,7 @@ "Combination27": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5010,9 +4905,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5020,10 +4912,7 @@ "Combination28": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5032,9 +4921,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5042,10 +4928,7 @@ "Combination29": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5054,9 +4937,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5064,10 +4944,7 @@ "Combination30": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5076,9 +4953,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5086,10 +4960,7 @@ "Combination31": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5098,9 +4969,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5108,10 +4976,7 @@ "Combination32": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5120,9 +4985,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5130,10 +4992,7 @@ "Combination33": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5142,9 +5001,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5152,10 +5008,7 @@ "Combination34": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5164,9 +5017,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5174,10 +5024,7 @@ "Combination35": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5186,9 +5033,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5196,10 +5040,7 @@ "Combination36": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5208,9 +5049,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5218,10 +5056,7 @@ "Combination37": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5230,9 +5065,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5240,10 +5072,7 @@ "Combination38": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5252,9 +5081,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5262,10 +5088,7 @@ "Combination39": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5274,9 +5097,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5284,10 +5104,7 @@ "Combination40": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5296,9 +5113,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5306,10 +5120,7 @@ "Combination41": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5318,9 +5129,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5328,10 +5136,7 @@ "Combination42": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5340,9 +5145,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5350,10 +5152,7 @@ "Combination43": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5362,9 +5161,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5372,10 +5168,7 @@ "Combination44": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5384,9 +5177,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5394,10 +5184,7 @@ "Combination45": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5406,9 +5193,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5416,10 +5200,7 @@ "Combination46": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5428,9 +5209,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5438,10 +5216,7 @@ "Combination47": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5450,9 +5225,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5460,10 +5232,7 @@ "Combination48": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5472,9 +5241,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5482,10 +5248,7 @@ "Combination49": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5494,9 +5257,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5504,10 +5264,7 @@ "Combination50": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5516,9 +5273,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5526,10 +5280,7 @@ "Combination51": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5538,9 +5289,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5548,10 +5296,7 @@ "Combination52": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5560,9 +5305,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5570,10 +5312,7 @@ "Combination53": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5582,9 +5321,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5592,10 +5328,7 @@ "Combination54": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5604,9 +5337,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5614,10 +5344,7 @@ "Combination55": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5626,9 +5353,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5636,10 +5360,7 @@ "Combination56": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5648,9 +5369,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5658,10 +5376,7 @@ "Combination57": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5670,9 +5385,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5680,10 +5392,7 @@ "Combination58": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5692,9 +5401,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5702,10 +5408,7 @@ "Combination59": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5714,9 +5417,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5724,10 +5424,7 @@ "Combination60": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5736,9 +5433,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5746,10 +5440,7 @@ "Combination61": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5758,9 +5449,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5768,10 +5456,7 @@ "Combination62": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5780,9 +5465,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5790,10 +5472,7 @@ "Combination63": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5802,9 +5481,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5812,10 +5488,7 @@ "Combination64": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5824,9 +5497,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5834,10 +5504,7 @@ "Combination65": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5846,9 +5513,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5856,10 +5520,7 @@ "Combination66": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5868,9 +5529,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5878,10 +5536,7 @@ "Combination67": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5890,9 +5545,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5900,10 +5552,7 @@ "Combination68": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5912,9 +5561,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5922,10 +5568,7 @@ "Combination69": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5934,9 +5577,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5944,10 +5584,7 @@ "Combination70": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5956,9 +5593,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5966,10 +5600,7 @@ "Combination71": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -5978,9 +5609,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -5988,10 +5616,7 @@ "Combination72": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6000,9 +5625,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6010,10 +5632,7 @@ "Combination73": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6022,9 +5641,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6032,10 +5648,7 @@ "Combination74": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6044,9 +5657,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6054,10 +5664,7 @@ "Combination75": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6066,9 +5673,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6076,10 +5680,7 @@ "Combination76": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6088,9 +5689,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6098,10 +5696,7 @@ "Combination77": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6110,9 +5705,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6120,10 +5712,7 @@ "Combination78": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6132,9 +5721,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6142,10 +5728,7 @@ "Combination79": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6153,10 +5736,7 @@ "Combination169": { "group": [ { - "id": "AccessToken" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6164,10 +5744,7 @@ "Combination80": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6176,9 +5753,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6186,10 +5760,7 @@ "Combination81": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6198,9 +5769,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6208,10 +5776,7 @@ "Combination82": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6220,9 +5785,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6230,10 +5792,7 @@ "Combination83": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6242,9 +5801,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6252,10 +5808,7 @@ "Combination84": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6264,9 +5817,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6274,10 +5824,7 @@ "Combination85": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6286,9 +5833,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6296,10 +5840,7 @@ "Combination86": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6308,9 +5849,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6318,10 +5856,7 @@ "Combination87": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6330,9 +5865,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6340,10 +5872,7 @@ "Combination88": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6352,9 +5881,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6362,10 +5888,7 @@ "Combination89": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6374,9 +5897,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6384,10 +5904,7 @@ "Combination90": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6396,9 +5913,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6406,10 +5920,7 @@ "Combination91": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6418,9 +5929,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6428,10 +5936,7 @@ "Combination92": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6440,9 +5945,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6450,10 +5952,7 @@ "Combination93": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6462,9 +5961,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6472,10 +5968,7 @@ "Combination94": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6484,9 +5977,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6494,10 +5984,7 @@ "Combination95": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6506,9 +5993,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6516,10 +6000,7 @@ "Combination96": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6528,9 +6009,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6538,10 +6016,7 @@ "Combination97": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6550,9 +6025,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6560,10 +6032,7 @@ "Combination98": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6572,9 +6041,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6582,10 +6048,7 @@ "Combination99": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6594,9 +6057,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6604,10 +6064,7 @@ "Combination100": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6616,9 +6073,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6626,10 +6080,7 @@ "Combination101": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6638,9 +6089,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6648,10 +6096,7 @@ "Combination102": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6660,9 +6105,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6670,10 +6112,7 @@ "Combination103": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6682,9 +6121,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6692,10 +6128,7 @@ "Combination104": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6704,9 +6137,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6714,10 +6144,7 @@ "Combination105": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6726,9 +6153,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6736,10 +6160,7 @@ "Combination106": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6748,9 +6169,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6758,10 +6176,7 @@ "Combination107": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6770,9 +6185,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6780,10 +6192,7 @@ "Combination108": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6792,9 +6201,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6802,10 +6208,7 @@ "Combination109": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6814,9 +6217,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6824,10 +6224,7 @@ "Combination200": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6836,9 +6233,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6846,10 +6240,7 @@ "Combination201": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6858,9 +6249,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6868,10 +6256,7 @@ "Combination202": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6880,9 +6265,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6890,10 +6272,7 @@ "Combination203": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6902,9 +6281,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6912,10 +6288,7 @@ "Combination204": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6924,9 +6297,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6934,10 +6304,7 @@ "Combination205": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6946,9 +6313,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6956,10 +6320,7 @@ "Combination206": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6968,9 +6329,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -6978,10 +6336,7 @@ "Combination207": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -6990,9 +6345,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7000,10 +6352,7 @@ "Combination208": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7012,9 +6361,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7022,10 +6368,7 @@ "Combination209": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7034,9 +6377,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7044,10 +6384,7 @@ "Combination210": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7056,9 +6393,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7066,10 +6400,7 @@ "Combination211": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7078,9 +6409,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7088,10 +6416,7 @@ "Combination212": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7100,9 +6425,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7110,10 +6432,7 @@ "Combination213": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7122,9 +6441,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7132,10 +6448,7 @@ "Combination214": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7144,9 +6457,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7154,10 +6464,7 @@ "Combination215": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7166,9 +6473,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7176,10 +6480,7 @@ "Combination216": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7188,9 +6489,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7198,10 +6496,7 @@ "Combination217": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7210,9 +6505,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7220,10 +6512,7 @@ "Combination218": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7232,9 +6521,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7242,10 +6528,7 @@ "Combination219": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7254,9 +6537,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" @@ -7264,10 +6544,7 @@ "Combination220": { "group": [ { - "id": "SessionAuth" - }, - { - "id": "NewApiUser" + "id": "AccessToken" } ], "type": "combination" @@ -7276,9 +6553,6 @@ "group": [ { "id": "AccessToken" - }, - { - "id": "NewApiUser" } ], "type": "combination" diff --git a/go.mod b/go.mod index f44126002065..98f291f64510 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,6 @@ require ( github.com/casbin/casbin/v2 v2.135.0 github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/gzip v0.0.6 - github.com/gin-contrib/sessions v0.0.5 github.com/gin-contrib/static v0.0.1 github.com/gin-gonic/gin v1.9.1 github.com/glebarez/sqlite v1.9.0 @@ -78,11 +77,15 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/segmentio/asm v1.2.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect ) -require github.com/Azure/go-ntlmssp v0.1.1 +require ( + github.com/Azure/go-ntlmssp v0.1.1 + github.com/alicebob/miniredis/v2 v2.38.0 +) require ( github.com/DmitriyVTitov/size v1.5.0 // indirect @@ -114,9 +117,6 @@ require ( github.com/go-webauthn/x v0.1.25 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-tpm v0.9.5 // indirect - github.com/gorilla/context v1.1.1 // indirect - github.com/gorilla/securecookie v1.1.1 // indirect - github.com/gorilla/sessions v1.2.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/icza/bitio v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index e2faeb0d096d..ca6bd091f831 100644 --- a/go.sum +++ b/go.sum @@ -697,6 +697,8 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk5 github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0= github.com/alexflint/go-filemutex v1.1.0/go.mod h1:7P4iRhttt/nUvUOrYIhcpMzv2G6CY9UnI16Z+UJqRyk= github.com/alexflint/go-filemutex v1.2.0/go.mod h1:mYyQSWvw9Tx2/H2n9qXPb52tTYfE0pZAWcBq5mK025c= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= @@ -1100,8 +1102,6 @@ github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQ github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= -github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE= -github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U= @@ -1359,18 +1359,12 @@ github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97Dwqy github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= -github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -2031,6 +2025,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= diff --git a/main.go b/main.go index 770ea156ba86..d2ed52384812 100644 --- a/main.go +++ b/main.go @@ -32,8 +32,6 @@ import ( "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/bytedance/gopkg/util/gopool" - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/joho/godotenv" @@ -188,17 +186,6 @@ func main() { server.Use(middleware.Version()) server.Use(middleware.I18n()) middleware.SetUpLogger(server) - // Initialize session store - store := cookie.NewStore([]byte(common.SessionSecret)) - store.Options(sessions.Options{ - Path: "/", - MaxAge: 2592000, // 30 days - HttpOnly: true, - Secure: common.SessionCookieSecure, - SameSite: http.SameSiteStrictMode, - }) - server.Use(sessions.Sessions("session", store)) - InjectUmamiAnalytics() InjectGoogleAnalytics() @@ -369,5 +356,7 @@ func InitResources() error { // Don't return error, custom OAuth is not critical } + service.StartAuthArtifactCleanup() + return nil } diff --git a/middleware/auth.go b/middleware/auth.go index 86abddc79945..d259d715811b 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -5,7 +5,6 @@ import ( "fmt" "net" "net/http" - "strconv" "strings" "github.com/QuantumNous/new-api/common" @@ -18,11 +17,12 @@ import ( "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "gorm.io/gorm" ) +const authIdentityContextKey = "auth_identity" + func validUserInfo(username string, role int) bool { // check username is empty if strings.TrimSpace(username) == "" { @@ -35,124 +35,24 @@ func validUserInfo(username string, role int) bool { } func authHelper(c *gin.Context, minRole int) { - session := sessions.Default(c) - username := session.Get("username") - role := session.Get("role") - id := session.Get("id") - status := session.Get("status") - useAccessToken := false - if username == nil { - // Check access token - accessToken := c.Request.Header.Get("Authorization") - if accessToken == "" { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn), - }) - c.Abort() - return - } - user, authErr := model.ValidateAccessToken(accessToken) - if authErr != nil { - if errors.Is(authErr, model.ErrDatabase) { - common.SysLog("ValidateAccessToken database error: " + authErr.Error()) - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": common.TranslateMessage(c, i18n.MsgDatabaseError), - }) - } else { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid), - }) - } - c.Abort() - return - } - if user != nil && user.Username != "" { - if !validUserInfo(user.Username, user.Role) { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid), - }) - c.Abort() - return - } - // Token is valid - username = user.Username - role = user.Role - id = user.Id - status = user.Status - useAccessToken = true - } else { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid), - }) - c.Abort() - return - } - } - // get header New-Api-User - apiUserIdStr := c.Request.Header.Get("New-Api-User") - if apiUserIdStr == "" { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": common.TranslateMessage(c, i18n.MsgAuthUserIdNotProvided), - }) - c.Abort() - return - } - apiUserId, err := strconv.Atoi(apiUserIdStr) + user, identity, useAccessToken, err := authenticateDashboardRequest(c) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": common.TranslateMessage(c, i18n.MsgAuthUserIdFormatError), - }) - c.Abort() + writeDashboardAuthError(c, err) return - } - if id != apiUserId { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch), - }) - c.Abort() - return - } - if status.(int) == common.UserStatusDisabled { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": common.TranslateMessage(c, i18n.MsgAuthUserBanned), - }) - c.Abort() + if user.Status != common.UserStatusEnabled { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_USER_DISABLED", "message": common.TranslateMessage(c, i18n.MsgAuthUserBanned)}) return } - if role.(int) < minRole { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege), - }) - c.Abort() + if user.Role < minRole { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"success": false, "code": "AUTH_INSUFFICIENT_PRIVILEGE", "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege)}) return } - if !validUserInfo(username.(string), role.(int)) { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid), - }) - c.Abort() + if !validUserInfo(user.Username, user.Role) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_USER_INVALID", "message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid)}) return } - // 防止不同newapi版本冲突,导致数据不通用 - c.Header("Auth-Version", "864b7076dbcd0a3c01b5520316720ebf") - c.Set("username", username) - c.Set("role", role) - c.Set("id", id) - c.Set("group", session.Get("group")) - c.Set("user_group", session.Get("group")) - c.Set("use_access_token", useAccessToken) + setDashboardAuthContext(c, user, identity, useAccessToken) // 管理/root 写操作审计兜底:内聚在鉴权链路里,保证任何经过 AdminAuth/RootAuth // 的写接口都会自动留痕(无需在路由上单独挂审计中间件,避免漏挂)。 @@ -169,10 +69,14 @@ func authHelper(c *gin.Context, minRole int) { func TryUserAuth() func(c *gin.Context) { return func(c *gin.Context) { - session := sessions.Default(c) - id := session.Get("id") - if id != nil { - c.Set("id", id) + _, ok := authorizationToken(c.GetHeader("Authorization")) + if ok { + user, identity, useAccessToken, err := authenticateDashboardRequest(c) + if err != nil { + writeDashboardAuthError(c, err) + return + } + setDashboardAuthContext(c, user, identity, useAccessToken) } c.Next() } @@ -196,6 +100,111 @@ func RootAuth() func(c *gin.Context) { } } +// GetAuthIdentity returns a dashboard session identity. PAT-authenticated +// requests intentionally have no SessionID and cannot manage browser sessions. +func GetAuthIdentity(c *gin.Context) (service.AuthIdentity, bool) { + value, ok := c.Get(authIdentityContextKey) + if !ok { + return service.AuthIdentity{}, false + } + identity, ok := value.(service.AuthIdentity) + return identity, ok +} + +// GetSessionAuthIdentity returns only identities backed by a live dashboard +// session. PAT-authenticated requests intentionally fail this check. +func GetSessionAuthIdentity(c *gin.Context) (service.AuthIdentity, bool) { + identity, ok := GetAuthIdentity(c) + if !ok { + identity = service.AuthIdentity{ + UserID: c.GetInt("id"), + SessionID: c.GetString("session_id"), + UserAuthVersion: c.GetInt64("auth_version"), + SessionVersion: c.GetInt64("session_version"), + } + } + if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 { + return service.AuthIdentity{}, false + } + return identity, true +} + +func authenticateDashboardRequest(c *gin.Context) (*model.UserBase, service.AuthIdentity, bool, error) { + raw, ok := authorizationToken(c.GetHeader("Authorization")) + if !ok { + return nil, service.AuthIdentity{}, false, service.ErrAuthTokenInvalid + } + identity, internal, err := service.ParseDashboardAccessToken(raw) + if internal { + if err != nil { + return nil, service.AuthIdentity{}, false, err + } + _, user, err := service.ValidateLoginSession(identity) + if err != nil { + return nil, service.AuthIdentity{}, false, err + } + return user, identity, false, nil + } + patUser, err := model.ValidateAccessToken(raw) + if err != nil { + return nil, service.AuthIdentity{}, true, err + } + if patUser == nil || patUser.Id <= 0 { + return nil, service.AuthIdentity{}, true, service.ErrAuthTokenInvalid + } + user, err := model.GetUserCache(patUser.Id) + if err != nil { + return nil, service.AuthIdentity{}, true, err + } + return user, service.AuthIdentity{UserID: user.Id, UserAuthVersion: user.AuthVersion}, true, nil +} + +func authorizationToken(header string) (string, bool) { + header = strings.TrimSpace(header) + if header == "" { + return "", false + } + parts := strings.Fields(header) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + header = parts[1] + } else if len(parts) != 1 { + return "", false + } + return header, header != "" +} + +func setDashboardAuthContext(c *gin.Context, user *model.UserBase, identity service.AuthIdentity, useAccessToken bool) { + c.Header("Auth-Version", "864b7076dbcd0a3c01b5520316720ebf") + c.Set("username", user.Username) + c.Set("role", user.Role) + c.Set("id", user.Id) + c.Set("group", user.Group) + c.Set("user_group", user.Group) + c.Set("use_access_token", useAccessToken) + c.Set("session_id", identity.SessionID) + c.Set("auth_version", identity.UserAuthVersion) + c.Set("session_version", identity.SessionVersion) + c.Set(authIdentityContextKey, identity) + user.WriteContext(c) +} + +func writeDashboardAuthError(c *gin.Context, err error) { + if errors.Is(err, service.ErrAuthTokenExpired) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_TOKEN_EXPIRED", "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn)}) + return + } + if errors.Is(err, service.ErrLoginSessionRevoked) || errors.Is(err, gorm.ErrRecordNotFound) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_SESSION_REVOKED", "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn)}) + return + } + if errors.Is(err, service.ErrAuthTokenInvalid) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_UNAUTHORIZED", "message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid)}) + return + } + common.SysLog("dashboard authentication error: " + err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"success": false, "code": "AUTH_INTERNAL_ERROR", "message": common.TranslateMessage(c, i18n.MsgDatabaseError)}) +} + func RequirePermission(permission authz.Permission) func(c *gin.Context) { return func(c *gin.Context) { role := c.GetInt("role") @@ -220,16 +229,27 @@ func WssAuth(c *gin.Context) { // Used for endpoints that need to be accessible from both the dashboard and API clients. func TokenOrUserAuth() func(c *gin.Context) { return func(c *gin.Context) { - // Try session auth first (dashboard users) - session := sessions.Default(c) - if id := session.Get("id"); id != nil { - if status, ok := session.Get("status").(int); ok && status == common.UserStatusEnabled { - c.Set("id", id) - c.Next() + raw, ok := authorizationToken(c.GetHeader("Authorization")) + if ok { + identity, internal, err := service.ParseDashboardAccessToken(raw) + if !internal { + TokenAuth()(c) return } + if err != nil { + writeDashboardAuthError(c, err) + return + } + _, user, err := service.ValidateLoginSession(identity) + if err != nil { + writeDashboardAuthError(c, err) + return + } + setDashboardAuthContext(c, user, identity, false) + c.Next() + return } - // Fall back to token auth (API clients) + // Opaque credentials are relay API keys here, never dashboard PATs. TokenAuth()(c) } } diff --git a/middleware/auth_origin.go b/middleware/auth_origin.go new file mode 100644 index 000000000000..f45686bd76b6 --- /dev/null +++ b/middleware/auth_origin.go @@ -0,0 +1,76 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" + "net/url" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" +) + +// SessionCookieOriginGuard protects cookie-authenticated refresh/logout +// endpoints when secure cookie mode is enabled. In insecure local development +// mode it preserves the legacy behavior and intentionally performs no Origin +// validation. It never adds CORS response headers and must not be installed on +// relay routes. +func SessionCookieOriginGuard() gin.HandlerFunc { + return func(c *gin.Context) { + if !common.SessionCookieSecure { + c.Next() + return + } + origin, ok := requestBrowserOrigin(c.Request) + if !ok || !isAllowedSessionOrigin(c.Request, origin) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "success": false, + "code": "AUTH_ORIGIN_FORBIDDEN", + "message": "request origin is not allowed", + }) + return + } + c.Next() + } +} + +func requestBrowserOrigin(request *http.Request) (string, bool) { + originValues := request.Header.Values("Origin") + if len(originValues) > 1 { + return "", false + } + if len(originValues) == 1 { + if strings.Contains(originValues[0], ",") { + return "", false + } + origin, err := common.NormalizeOrigin(originValues[0]) + return origin, err == nil + } + refererValues := request.Header.Values("Referer") + if len(refererValues) != 1 { + return "", false + } + referer, err := url.Parse(strings.TrimSpace(refererValues[0])) + if err != nil || referer.Scheme == "" || referer.Host == "" || referer.User != nil { + return "", false + } + origin, err := common.NormalizeOrigin(referer.Scheme + "://" + referer.Host) + return origin, err == nil +} + +func isAllowedSessionOrigin(request *http.Request, origin string) bool { + requestScheme := "http" + if request.TLS != nil { + requestScheme = "https" + } + requestOrigin, err := common.NormalizeOrigin(requestScheme + "://" + request.Host) + if err == nil && subtle.ConstantTimeCompare([]byte(origin), []byte(requestOrigin)) == 1 { + return true + } + for _, trustedOrigin := range common.SessionCookieTrustedURLs { + if subtle.ConstantTimeCompare([]byte(origin), []byte(trustedOrigin)) == 1 { + return true + } + } + return false +} diff --git a/middleware/auth_origin_test.go b/middleware/auth_origin_test.go new file mode 100644 index 000000000000..81f1dec0f3d5 --- /dev/null +++ b/middleware/auth_origin_test.go @@ -0,0 +1,135 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func runOriginGuardRequest(t *testing.T, origin, referer string) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/api/user/auth/refresh", SessionCookieOriginGuard(), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodPost, "https://panel.example.com/api/user/auth/refresh", nil) + request.Host = "panel.example.com" + request.Header.Set("Origin", origin) + if origin == "" { + request.Header.Del("Origin") + } + if referer != "" { + request.Header.Set("Referer", referer) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +func TestSessionCookieOriginGuard(t *testing.T) { + previousSecure := common.SessionCookieSecure + previousTrustedURLs := common.SessionCookieTrustedURLs + common.SessionCookieSecure = true + common.SessionCookieTrustedURLs = []string{"https://trusted.example.com"} + t.Cleanup(func() { + common.SessionCookieSecure = previousSecure + common.SessionCookieTrustedURLs = previousTrustedURLs + }) + + tests := []struct { + name string + origin string + referer string + expected int + }{ + {name: "same origin", origin: "https://panel.example.com", expected: http.StatusNoContent}, + {name: "trusted exact origin", origin: "https://trusted.example.com", expected: http.StatusNoContent}, + {name: "referer fallback", referer: "https://panel.example.com/profile", expected: http.StatusNoContent}, + {name: "missing both", expected: http.StatusForbidden}, + {name: "null origin", origin: "null", expected: http.StatusForbidden}, + {name: "suffix attack", origin: "https://trusted.example.com.evil.test", expected: http.StatusForbidden}, + {name: "scheme mismatch", origin: "http://panel.example.com", expected: http.StatusForbidden}, + {name: "path in origin", origin: "https://panel.example.com/profile", expected: http.StatusForbidden}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := runOriginGuardRequest(t, test.origin, test.referer) + assert.Equal(t, test.expected, response.Code) + assert.Empty(t, response.Header().Get("Access-Control-Allow-Origin")) + }) + } +} + +func TestSessionCookieOriginGuardDevelopmentCompatibility(t *testing.T) { + previousSecure := common.SessionCookieSecure + previousTrustedURLs := common.SessionCookieTrustedURLs + t.Cleanup(func() { + common.SessionCookieSecure = previousSecure + common.SessionCookieTrustedURLs = previousTrustedURLs + }) + common.SessionCookieTrustedURLs = nil + + tests := []struct { + name string + secure bool + origin string + expected int + }{ + {name: "insecure mode allows mismatched development origins", origin: "http://localhost:3001", expected: http.StatusNoContent}, + {name: "insecure mode allows missing origin", expected: http.StatusNoContent}, + {name: "secure mode rejects mismatched development origins", secure: true, origin: "http://localhost:3001", expected: http.StatusForbidden}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + common.SessionCookieSecure = test.secure + + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/api/user/auth/refresh", SessionCookieOriginGuard(), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodPost, "http://localhost:3000/api/user/auth/refresh", nil) + request.Host = "localhost:3000" + if test.origin != "" { + request.Header.Set("Origin", test.origin) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + assert.Equal(t, test.expected, response.Code) + assert.Empty(t, response.Header().Get("Access-Control-Allow-Origin")) + }) + } +} + +func TestSessionCookieOriginGuardDoesNotTrustForwardedProtoFromClient(t *testing.T) { + previousSecure := common.SessionCookieSecure + previousTrustedURLs := common.SessionCookieTrustedURLs + common.SessionCookieSecure = true + common.SessionCookieTrustedURLs = nil + t.Cleanup(func() { + common.SessionCookieSecure = previousSecure + common.SessionCookieTrustedURLs = previousTrustedURLs + }) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/api/user/auth/refresh", SessionCookieOriginGuard(), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodPost, "http://panel.example.com/api/user/auth/refresh", nil) + request.Host = "panel.example.com" + request.Header.Set("Origin", "https://panel.example.com") + request.Header.Set("X-Forwarded-Proto", "https") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + assert.Equal(t, http.StatusForbidden, response.Code) +} diff --git a/middleware/auth_test.go b/middleware/auth_test.go new file mode 100644 index 000000000000..a42c09faa18a --- /dev/null +++ b/middleware/auth_test.go @@ -0,0 +1,94 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupDashboardAuthMiddlewareTest(t *testing.T) { + t.Helper() + previousDB := model.DB + previousType := common.MainDatabaseType() + previousRedis := common.RedisEnabled + previousSecret := common.SessionSecret + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{})) + model.DB = db + common.SetMainDatabaseType(common.DatabaseTypeSQLite) + common.RedisEnabled = false + common.SessionSecret = "middleware-auth-test-secret" + t.Cleanup(func() { + model.DB = previousDB + common.SetMainDatabaseType(previousType) + common.RedisEnabled = previousRedis + common.SessionSecret = previousSecret + }) +} + +func createMiddlewarePATUser(t *testing.T, username, token string) *model.User { + t.Helper() + user := &model.User{ + Username: username, Password: "password-placeholder", Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, Group: "default", AccessToken: &token, AuthVersion: 1, + } + require.NoError(t, model.DB.Create(user).Error) + return user +} + +func TestUserAuthAllowsOpaqueDottedPAT(t *testing.T) { + setupDashboardAuthMiddlewareTest(t) + user := createMiddlewarePATUser(t, "dotted-pat-user", "opaque.key.with-dots") + router := gin.New() + router.GET("/protected", UserAuth(), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"id": c.GetInt("id")}) + }) + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + request.Header.Set("Authorization", "Bearer opaque.key.with-dots") + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + assert.Equal(t, http.StatusOK, response.Code) + var body struct { + ID int `json:"id"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + assert.Equal(t, user.Id, body.ID) +} + +func TestUserAuthNeverFallsBackForRecognizedInvalidInternalJWT(t *testing.T) { + setupDashboardAuthMiddlewareTest(t) + identity := service.AuthIdentity{UserID: 42, SessionID: "session-42", UserAuthVersion: 1, SessionVersion: 1} + token, _, err := service.IssueAccessToken(identity) + require.NoError(t, err) + tamperAt := len(token) - 2 + replacement := "x" + if token[tamperAt] == 'x' { + replacement = "y" + } + tampered := token[:tamperAt] + replacement + token[tamperAt+1:] + createMiddlewarePATUser(t, "jwt-fallback-user", tampered) + router := gin.New() + router.GET("/protected", UserAuth(), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + request.Header.Set("Authorization", "Bearer "+tampered) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + assert.Equal(t, http.StatusUnauthorized, response.Code) + assert.Contains(t, response.Body.String(), "AUTH_UNAUTHORIZED") +} diff --git a/middleware/header_nav_test.go b/middleware/header_nav_test.go index d4c9c221ef2d..83852d3f6ad8 100644 --- a/middleware/header_nav_test.go +++ b/middleware/header_nav_test.go @@ -6,10 +6,11 @@ import ( "testing" "github.com/QuantumNous/new-api/common" - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" + "github.com/QuantumNous/new-api/model" "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) func withHeaderNavModules(t *testing.T, raw string) { @@ -39,40 +40,39 @@ func performHeaderNavRequest(t *testing.T, handler gin.HandlerFunc, authenticate gin.SetMode(gin.TestMode) router := gin.New() - router.Use(sessions.Sessions("session", cookie.NewStore([]byte("header-nav-test")))) - router.GET("/login", func(c *gin.Context) { - session := sessions.Default(c) - session.Set("username", "tester") - session.Set("role", common.RoleCommonUser) - session.Set("id", 1) - session.Set("status", common.UserStatusEnabled) - session.Set("group", "default") - if err := session.Save(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"success": false}) - return - } - c.Status(http.StatusNoContent) - }) router.GET("/api/test", handler, func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true}) }) - var cookies []*http.Cookie + var accessToken string if authenticated { - loginRecorder := httptest.NewRecorder() - loginRequest := httptest.NewRequest(http.MethodGet, "/login", nil) - router.ServeHTTP(loginRecorder, loginRequest) - require.Equal(t, http.StatusNoContent, loginRecorder.Code) - cookies = loginRecorder.Result().Cookies() + previousDB, previousRedis := model.DB, common.RedisEnabled + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{})) + model.DB = db + common.RedisEnabled = false + t.Cleanup(func() { + model.DB = previousDB + common.RedisEnabled = previousRedis + }) + accessToken = "header-nav-pat" + user := model.User{ + Username: "tester", + Password: "unused-password-hash", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + AuthVersion: 1, + } + user.SetAccessToken(accessToken) + require.NoError(t, db.Create(&user).Error) } recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/test", nil) if authenticated { - request.Header.Set("New-Api-User", "1") - for _, cookie := range cookies { - request.AddCookie(cookie) - } + request.Header.Set("Authorization", "Bearer "+accessToken) } router.ServeHTTP(recorder, request) return recorder diff --git a/middleware/secure_verification.go b/middleware/secure_verification.go index b218f6b1bbe1..53c47c885930 100644 --- a/middleware/secure_verification.go +++ b/middleware/secure_verification.go @@ -1,133 +1,60 @@ package middleware import ( + "errors" "net/http" - "time" + "strings" - "github.com/gin-contrib/sessions" + "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" ) -const ( - // SecureVerificationSessionKey 安全验证的 session key(与 controller 保持一致) - SecureVerificationSessionKey = "secure_verified_at" - secureVerificationMethodSessionKey = "secure_verified_method" - // SecureVerificationTimeout 验证有效期(秒) - SecureVerificationTimeout = 300 // 5分钟 -) - -// SecureVerificationRequired 安全验证中间件 -// 检查用户是否在有效时间内通过了安全验证 -// 如果未验证或验证已过期,返回 401 错误 +// SecureVerificationRequired protects channel key disclosure. Other sensitive +// operations validate their narrower proof scopes in their controller. func SecureVerificationRequired() gin.HandlerFunc { return func(c *gin.Context) { - // 检查用户是否已登录 - userId := c.GetInt("id") - if userId == 0 { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": "未登录", - }) - c.Abort() - return - } - - // 检查 session 中的验证时间戳 - session := sessions.Default(c) - verifiedAtRaw := session.Get(SecureVerificationSessionKey) - - if verifiedAtRaw == nil { - c.JSON(http.StatusForbidden, gin.H{ - "success": false, - "message": "需要安全验证", - "code": "VERIFICATION_REQUIRED", - }) - c.Abort() - return - } - - verifiedAt, ok := verifiedAtRaw.(int64) - if !ok { - // session 数据格式错误 - clearSecureVerificationSession(session) - c.JSON(http.StatusForbidden, gin.H{ - "success": false, - "message": "验证状态异常,请重新验证", - "code": "VERIFICATION_INVALID", - }) - c.Abort() + if !RequireSecurityProof(c, "channel.key.read", []string{"2fa", "passkey"}) { return } - - // 检查验证是否过期 - elapsed := time.Now().Unix() - verifiedAt - if elapsed >= SecureVerificationTimeout { - // 验证已过期,清除 session - clearSecureVerificationSession(session) - c.JSON(http.StatusForbidden, gin.H{ - "success": false, - "message": "验证已过期,请重新验证", - "code": "VERIFICATION_EXPIRED", - }) - c.Abort() - return - } - + c.Set("secure_verified", true) c.Next() } } -func clearSecureVerificationSession(session sessions.Session) { - session.Delete(SecureVerificationSessionKey) - session.Delete(secureVerificationMethodSessionKey) - _ = session.Save() -} - -// OptionalSecureVerification 可选的安全验证中间件 -// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续 -// 用于某些需要区分是否已验证的场景 -func OptionalSecureVerification() gin.HandlerFunc { - return func(c *gin.Context) { - userId := c.GetInt("id") - if userId == 0 { - c.Set("secure_verified", false) - c.Next() - return - } - - session := sessions.Default(c) - verifiedAtRaw := session.Get(SecureVerificationSessionKey) - - if verifiedAtRaw == nil { - c.Set("secure_verified", false) - c.Next() - return - } - - verifiedAt, ok := verifiedAtRaw.(int64) - if !ok { - c.Set("secure_verified", false) - c.Next() - return - } - - elapsed := time.Now().Unix() - verifiedAt - if elapsed >= SecureVerificationTimeout { - clearSecureVerificationSession(session) - c.Set("secure_verified", false) - c.Next() - return - } - - c.Set("secure_verified", true) - c.Set("secure_verified_at", verifiedAt) - c.Next() +// RequireSecurityProof validates a proof against the authenticated dashboard +// session and writes the shared proof error contract on failure. +func RequireSecurityProof(c *gin.Context, requiredScope string, allowedMethods []string) bool { + identity, ok := GetSessionAuthIdentity(c) + if !ok { + securityProofError(c, "SECURITY_PROOF_INVALID", "安全验证状态无效") + return false + } + raw := strings.TrimSpace(c.GetHeader("X-Security-Proof")) + if raw == "" { + securityProofError(c, "SECURITY_PROOF_REQUIRED", "需要安全验证") + return false + } + if _, err := service.VerifySecurityProof(raw, identity, requiredScope, allowedMethods); err != nil { + switch { + case errors.Is(err, service.ErrAuthTokenExpired): + securityProofError(c, "SECURITY_PROOF_EXPIRED", "安全验证已过期") + case errors.Is(err, service.ErrProofScope): + securityProofError(c, "SECURITY_PROOF_SCOPE_MISMATCH", "安全验证范围不匹配") + case errors.Is(err, service.ErrProofMethod): + securityProofError(c, "SECURITY_PROOF_METHOD_MISMATCH", "安全验证方式不匹配") + default: + securityProofError(c, "SECURITY_PROOF_INVALID", "安全验证状态无效") + } + return false } + return true } -// ClearSecureVerification 清除安全验证状态 -// 用于用户登出或需要强制重新验证的场景 -func ClearSecureVerification(c *gin.Context) { - session := sessions.Default(c) - clearSecureVerificationSession(session) +func securityProofError(c *gin.Context, code, message string) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": message, + "code": code, + }) + c.Abort() } diff --git a/middleware/turnstile-check.go b/middleware/turnstile-check.go index af87fad4423c..6d7f1eeb732e 100644 --- a/middleware/turnstile-check.go +++ b/middleware/turnstile-check.go @@ -1,12 +1,10 @@ package middleware import ( - "encoding/json" "net/http" "net/url" "github.com/QuantumNous/new-api/common" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) @@ -17,12 +15,6 @@ type turnstileCheckResponse struct { func TurnstileCheck() gin.HandlerFunc { return func(c *gin.Context) { if common.TurnstileCheckEnabled { - session := sessions.Default(c) - turnstileChecked := session.Get("turnstile") - if turnstileChecked != nil { - c.Next() - return - } response := c.Query("turnstile") if response == "" { c.JSON(http.StatusOK, gin.H{ @@ -48,7 +40,7 @@ func TurnstileCheck() gin.HandlerFunc { } defer rawRes.Body.Close() var res turnstileCheckResponse - err = json.NewDecoder(rawRes.Body).Decode(&res) + err = common.DecodeJson(rawRes.Body, &res) if err != nil { common.SysLog(err.Error()) c.JSON(http.StatusOK, gin.H{ @@ -66,15 +58,6 @@ func TurnstileCheck() gin.HandlerFunc { c.Abort() return } - session.Set("turnstile", true) - err = session.Save() - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "message": "无法保存会话信息,请重试", - "success": false, - }) - return - } } c.Next() } diff --git a/model/auth_flow.go b/model/auth_flow.go new file mode 100644 index 000000000000..96bb72065a64 --- /dev/null +++ b/model/auth_flow.go @@ -0,0 +1,236 @@ +package model + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + AuthFlowPurposeOAuth = "oauth" + AuthFlowPurposeTwoFALogin = "2fa_login" + AuthFlowPurposePasskeyLogin = "passkey_login" + AuthFlowPurposePasskeyRegister = "passkey_register" + AuthFlowPurposePasskeyStepUp = "passkey_step_up" + AuthFlowPurposeTelegramBind = "telegram_bind" + AuthFlowPurposeTelegramAssertion = "telegram_assertion" + AuthFlowIntentLogin = "login" + AuthFlowIntentBind = "bind" + AuthFlowTokenBytes = 32 + AuthFlowDefaultCleanupRetention = 24 * time.Hour +) + +var ( + ErrAuthFlowInvalid = errors.New("auth flow is invalid") + ErrAuthFlowExpired = errors.New("auth flow has expired") + ErrAuthFlowConsumed = errors.New("auth flow has already been consumed") +) + +// AuthFlow stores one-time, short-lived state for authentication ceremonies. +// TokenHash is an HMAC of the opaque token; the token itself is never persisted. +type AuthFlow struct { + Id int64 `json:"id" gorm:"primaryKey"` + TokenHash string `json:"-" gorm:"type:char(64);not null;uniqueIndex"` + Purpose string `json:"purpose" gorm:"type:varchar(32);not null;index:idx_auth_flow_purpose_expiry"` + Provider string `json:"provider,omitempty" gorm:"type:varchar(64)"` + Intent string `json:"intent,omitempty" gorm:"type:varchar(16)"` + UserId int `json:"user_id,omitempty" gorm:"index"` + SessionId string `json:"session_id,omitempty" gorm:"type:varchar(64);index"` + Payload string `json:"-" gorm:"type:text"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at" gorm:"not null;index:idx_auth_flow_purpose_expiry"` + ConsumedAt *time.Time `json:"consumed_at,omitempty" gorm:"index"` +} + +func (AuthFlow) TableName() string { + return "auth_flows" +} + +type AuthFlowCreate struct { + Purpose string + Provider string + Intent string + UserId int + SessionId string + Payload string + ExpiresAt time.Time +} + +type AuthFlowMatch struct { + Purpose string + Provider string + Intent string + UserId int + SessionId string +} + +func applyAuthFlowMatch(query *gorm.DB, token string, match AuthFlowMatch) *gorm.DB { + query = query.Where("token_hash = ? AND purpose = ?", authFlowTokenHash(token), match.Purpose) + if match.Provider != "" { + query = query.Where("provider = ?", match.Provider) + } + if match.Intent != "" { + query = query.Where("intent = ?", match.Intent) + } + if match.UserId != 0 { + query = query.Where("user_id = ?", match.UserId) + } + if match.SessionId != "" { + query = query.Where("session_id = ?", match.SessionId) + } + return query +} + +func authFlowTokenHash(token string) string { + return common.GenerateHMACWithKey([]byte("auth-flow-v1:"+common.SessionSecret), token) +} + +func CreateAuthFlow(input AuthFlowCreate) (string, *AuthFlow, error) { + if strings.TrimSpace(input.Purpose) == "" || input.ExpiresAt.IsZero() || !input.ExpiresAt.After(time.Now()) { + return "", nil, ErrAuthFlowInvalid + } + random := make([]byte, AuthFlowTokenBytes) + if _, err := rand.Read(random); err != nil { + return "", nil, fmt.Errorf("generate auth flow token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(random) + flow := &AuthFlow{ + TokenHash: authFlowTokenHash(token), + Purpose: input.Purpose, + Provider: input.Provider, + Intent: input.Intent, + UserId: input.UserId, + SessionId: input.SessionId, + Payload: input.Payload, + ExpiresAt: input.ExpiresAt, + } + if err := DB.Create(flow).Error; err != nil { + return "", nil, err + } + return token, flow, nil +} + +// ClaimExternalAuthAssertion records a signed provider assertion as consumed. +// The assertion is HMACed before storage and the unique token_hash index makes +// replay rejection atomic on SQLite, MySQL and PostgreSQL. +func ClaimExternalAuthAssertion(purpose, assertion string, expiresAt time.Time) error { + return DB.Transaction(func(tx *gorm.DB) error { + return ClaimExternalAuthAssertionWithTx(tx, purpose, assertion, expiresAt) + }) +} + +// ClaimExternalAuthAssertionWithTx records a provider assertion in the +// caller's transaction so replay protection can commit atomically with the +// authentication flow and its resulting state change. +func ClaimExternalAuthAssertionWithTx(tx *gorm.DB, purpose, assertion string, expiresAt time.Time) error { + purpose = strings.TrimSpace(purpose) + assertion = strings.TrimSpace(assertion) + now := time.Now() + if tx == nil || purpose == "" || assertion == "" || !expiresAt.After(now) { + return ErrAuthFlowInvalid + } + flow := AuthFlow{ + TokenHash: authFlowTokenHash("external:" + purpose + ":" + assertion), + Purpose: purpose, + ExpiresAt: expiresAt, + ConsumedAt: &now, + } + result := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "token_hash"}}, + DoNothing: true, + }).Create(&flow) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrAuthFlowConsumed + } + return nil +} + +// GetAuthFlow validates a flow without consuming it. Callers must still use +// ConsumeAuthFlow with all identity-bound fields before performing the action. +func GetAuthFlow(token string, match AuthFlowMatch) (*AuthFlow, error) { + if token == "" || match.Purpose == "" { + return nil, ErrAuthFlowInvalid + } + var flow AuthFlow + if err := applyAuthFlowMatch(DB, token, match).First(&flow).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAuthFlowInvalid + } + return nil, err + } + if flow.ConsumedAt != nil { + return nil, ErrAuthFlowConsumed + } + if !flow.ExpiresAt.After(time.Now()) { + return nil, ErrAuthFlowExpired + } + return &flow, nil +} + +// ConsumeAuthFlow atomically validates and consumes a flow. Optional match +// fields are enforced when non-zero so tokens cannot cross purposes or users. +func ConsumeAuthFlow(token string, match AuthFlowMatch) (*AuthFlow, error) { + return ConsumeAuthFlowWithAction(token, match, nil) +} + +// ConsumeAuthFlowWithAction consumes a flow and runs action in the same +// database transaction. An action failure rolls the consumption back. +func ConsumeAuthFlowWithAction(token string, match AuthFlowMatch, action func(tx *gorm.DB, flow *AuthFlow) error) (*AuthFlow, error) { + if token == "" || match.Purpose == "" { + return nil, ErrAuthFlowInvalid + } + var consumed AuthFlow + err := DB.Transaction(func(tx *gorm.DB) error { + query := applyAuthFlowMatch(lockForUpdate(tx), token, match) + if err := query.First(&consumed).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrAuthFlowInvalid + } + return err + } + if consumed.ConsumedAt != nil { + return ErrAuthFlowConsumed + } + now := time.Now() + if !consumed.ExpiresAt.After(now) { + return ErrAuthFlowExpired + } + result := tx.Model(&AuthFlow{}). + Where("id = ? AND consumed_at IS NULL AND expires_at > ?", consumed.Id, now). + Update("consumed_at", now) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrAuthFlowConsumed + } + consumed.ConsumedAt = &now + if action != nil { + if err := action(tx, &consumed); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return &consumed, nil +} + +func DeleteExpiredAuthFlows(now time.Time) error { + cutoff := now.Add(-AuthFlowDefaultCleanupRetention) + return DB.Where("expires_at < ? OR (consumed_at IS NOT NULL AND consumed_at < ?)", cutoff, cutoff). + Delete(&AuthFlow{}).Error +} diff --git a/model/auth_flow_test.go b/model/auth_flow_test.go new file mode 100644 index 000000000000..d8b19b179f85 --- /dev/null +++ b/model/auth_flow_test.go @@ -0,0 +1,109 @@ +package model + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestAuthFlowIsBoundAndConsumedOnce(t *testing.T) { + truncateTables(t) + + token, created, err := CreateAuthFlow(AuthFlowCreate{ + Purpose: AuthFlowPurposeOAuth, + Provider: "github", + Intent: AuthFlowIntentBind, + UserId: 42, + SessionId: "session-a", + Payload: `{"affiliate_code":"invite"}`, + ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + require.NotEmpty(t, token) + assert.NotEqual(t, token, created.TokenHash) + + _, err = ConsumeAuthFlow(token, AuthFlowMatch{ + Purpose: AuthFlowPurposeOAuth, + Provider: "github", + Intent: AuthFlowIntentBind, + UserId: 99, + SessionId: "session-a", + }) + assert.ErrorIs(t, err, ErrAuthFlowInvalid) + + peeked, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeOAuth, Provider: "github"}) + require.NoError(t, err) + assert.Nil(t, peeked.ConsumedAt) + + consumed, err := ConsumeAuthFlow(token, AuthFlowMatch{ + Purpose: AuthFlowPurposeOAuth, + Provider: "github", + Intent: AuthFlowIntentBind, + UserId: 42, + SessionId: "session-a", + }) + require.NoError(t, err) + require.NotNil(t, consumed.ConsumedAt) + + _, err = ConsumeAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeOAuth}) + assert.ErrorIs(t, err, ErrAuthFlowConsumed) +} + +func TestAuthFlowExpiryIsEnforced(t *testing.T) { + truncateTables(t) + + token, flow, err := CreateAuthFlow(AuthFlowCreate{ + Purpose: AuthFlowPurposeTwoFALogin, + UserId: 7, + ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + require.NoError(t, DB.Model(&AuthFlow{}).Where("id = ?", flow.Id).Update("expires_at", time.Now().Add(-time.Second)).Error) + + _, err = GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTwoFALogin}) + assert.True(t, errors.Is(err, ErrAuthFlowExpired)) + _, err = ConsumeAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTwoFALogin}) + assert.True(t, errors.Is(err, ErrAuthFlowExpired)) +} + +func TestExternalAuthAssertionCanOnlyBeClaimedOnce(t *testing.T) { + truncateTables(t) + expiresAt := time.Now().Add(time.Minute) + + require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "signed-assertion", expiresAt)) + err := ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "signed-assertion", expiresAt) + assert.ErrorIs(t, err, ErrAuthFlowConsumed) + + require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "different-assertion", expiresAt)) +} + +func TestConsumeAuthFlowWithActionRollsBackTogether(t *testing.T) { + truncateTables(t) + token, _, err := CreateAuthFlow(AuthFlowCreate{ + Purpose: AuthFlowPurposeTelegramBind, + UserId: 42, + SessionId: "session-a", + ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + actionErr := errors.New("binding failed") + + _, err = ConsumeAuthFlowWithAction(token, AuthFlowMatch{ + Purpose: AuthFlowPurposeTelegramBind, UserId: 42, SessionId: "session-a", + }, func(tx *gorm.DB, _ *AuthFlow) error { + if err := ClaimExternalAuthAssertionWithTx(tx, AuthFlowPurposeTelegramAssertion, "assertion-a", time.Now().Add(time.Minute)); err != nil { + return err + } + return actionErr + }) + assert.ErrorIs(t, err, actionErr) + + flow, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTelegramBind}) + require.NoError(t, err) + assert.Nil(t, flow.ConsumedAt) + require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "assertion-a", time.Now().Add(time.Minute))) +} diff --git a/model/errors.go b/model/errors.go index 7f53a03de6c5..187daa6567ce 100644 --- a/model/errors.go +++ b/model/errors.go @@ -27,3 +27,4 @@ var ErrRedeemFailed = errors.New("redeem.failed") // 2FA errors var ErrTwoFANotEnabled = errors.New("2fa not enabled") +var ErrTwoFAAlreadyEnabled = errors.New("2fa already enabled") diff --git a/model/external_identity_claim.go b/model/external_identity_claim.go new file mode 100644 index 000000000000..7c5ee1f49a62 --- /dev/null +++ b/model/external_identity_claim.go @@ -0,0 +1,103 @@ +package model + +import ( + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ExternalIdentityProviderTelegram = "telegram" + +var ErrExternalIdentityAlreadyClaimed = errors.New("external identity is already claimed") + +// ExternalIdentityClaim is the durable ownership record for an identity issued +// by an external provider. The two unique indexes make both the provider +// subject and the user's provider slot single-owner without relying on a +// check-then-update sequence. +type ExternalIdentityClaim struct { + Id int64 `json:"id" gorm:"primaryKey"` + Provider string `json:"provider" gorm:"type:varchar(32);not null;uniqueIndex:idx_external_identity_subject,priority:1;uniqueIndex:idx_external_identity_user,priority:1"` + Subject string `json:"subject" gorm:"type:varchar(128);not null;uniqueIndex:idx_external_identity_subject,priority:2"` + UserId int `json:"user_id" gorm:"not null;index;uniqueIndex:idx_external_identity_user,priority:2"` + CreatedAt time.Time `json:"created_at"` +} + +func (ExternalIdentityClaim) TableName() string { + return "external_identity_claims" +} + +// ClaimExternalIdentityWithTx atomically claims a provider subject for one +// user. Repeating the exact mapping is idempotent; every competing subject or +// user is rejected. Ownership is read back instead of trusting RowsAffected, +// whose duplicate-key semantics differ between supported databases. +func ClaimExternalIdentityWithTx(tx *gorm.DB, provider, subject string, userId int) error { + provider = strings.TrimSpace(provider) + subject = strings.TrimSpace(subject) + if tx == nil || provider == "" || subject == "" || userId == 0 { + return errors.New("external identity claim is invalid") + } + + claim := ExternalIdentityClaim{Provider: provider, Subject: subject, UserId: userId} + result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&claim) + if result.Error != nil { + return result.Error + } + var subjectOwner ExternalIdentityClaim + if err := tx.Where("provider = ? AND subject = ?", provider, subject).First(&subjectOwner).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrExternalIdentityAlreadyClaimed + } + return err + } + if subjectOwner.UserId != userId { + return ErrExternalIdentityAlreadyClaimed + } + + var userClaim ExternalIdentityClaim + if err := tx.Where("provider = ? AND user_id = ?", provider, userId).First(&userClaim).Error; err != nil { + return err + } + if userClaim.Subject != subject { + return ErrExternalIdentityAlreadyClaimed + } + return nil +} + +func ReleaseExternalIdentityWithTx(tx *gorm.DB, provider string, userId int) error { + provider = strings.TrimSpace(provider) + if tx == nil || provider == "" || userId == 0 { + return errors.New("external identity release is invalid") + } + return tx.Where("provider = ? AND user_id = ?", provider, userId). + Delete(&ExternalIdentityClaim{}).Error +} + +func releaseAllExternalIdentitiesWithTx(tx *gorm.DB, userId int) error { + if tx == nil || userId == 0 { + return errors.New("external identity release is invalid") + } + return tx.Where("user_id = ?", userId).Delete(&ExternalIdentityClaim{}).Error +} + +// InitializeExternalIdentityClaims imports legacy Telegram bindings after the +// claim table is migrated. Existing duplicate ownership fails migration rather +// than preserving an ambiguous login identity. +func InitializeExternalIdentityClaims() error { + var users []User + if err := DB.Unscoped().Select("id", "telegram_id"). + Where("telegram_id <> ?", "").Find(&users).Error; err != nil { + return err + } + return DB.Transaction(func(tx *gorm.DB) error { + for _, user := range users { + if err := ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id); err != nil { + return fmt.Errorf("backfill Telegram identity for user %d: %w", user.Id, err) + } + } + return nil + }) +} diff --git a/model/external_identity_claim_test.go b/model/external_identity_claim_test.go new file mode 100644 index 000000000000..b428628da770 --- /dev/null +++ b/model/external_identity_claim_test.go @@ -0,0 +1,91 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestExternalIdentityClaimEnforcesSingleOwnerAtomically(t *testing.T) { + truncateTables(t) + + first := User{Username: "telegram-owner-one", Password: "password", AffCode: "telegram-owner-one"} + second := User{Username: "telegram-owner-two", Password: "password", AffCode: "telegram-owner-two"} + require.NoError(t, DB.Create(&first).Error) + require.NoError(t, DB.Create(&second).Error) + + require.NoError(t, DB.Transaction(func(tx *gorm.DB) error { + return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-123", first.Id) + })) + err := DB.Transaction(func(tx *gorm.DB) error { + return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-123", second.Id) + }) + assert.ErrorIs(t, err, ErrExternalIdentityAlreadyClaimed) + + err = DB.Transaction(func(tx *gorm.DB) error { + return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-456", first.Id) + }) + assert.ErrorIs(t, err, ErrExternalIdentityAlreadyClaimed) + + var claims []ExternalIdentityClaim + require.NoError(t, DB.Find(&claims).Error) + require.Len(t, claims, 1) + assert.Equal(t, first.Id, claims[0].UserId) + assert.Equal(t, "telegram-123", claims[0].Subject) + + require.NoError(t, DB.Transaction(func(tx *gorm.DB) error { + return ReleaseExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, first.Id) + })) + require.NoError(t, DB.Transaction(func(tx *gorm.DB) error { + return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-123", second.Id) + })) +} + +func TestClearTelegramBindingReleasesIdentityClaim(t *testing.T) { + truncateTables(t) + + user := User{Username: "telegram-unbind", Password: "password", TelegramId: "telegram-unbind-id"} + require.NoError(t, DB.Create(&user).Error) + require.NoError(t, DB.Transaction(func(tx *gorm.DB) error { + return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id) + })) + + require.NoError(t, user.ClearBinding(ExternalIdentityProviderTelegram)) + assert.Empty(t, user.TelegramId) + + var count int64 + require.NoError(t, DB.Model(&ExternalIdentityClaim{}).Where("user_id = ?", user.Id).Count(&count).Error) + assert.Zero(t, count) +} + +func TestInitializeExternalIdentityClaimsIsIdempotent(t *testing.T) { + truncateTables(t) + + user := User{Username: "telegram-legacy", Password: "password", TelegramId: "telegram-legacy-id"} + require.NoError(t, DB.Create(&user).Error) + require.NoError(t, InitializeExternalIdentityClaims()) + require.NoError(t, InitializeExternalIdentityClaims()) + + var claim ExternalIdentityClaim + require.NoError(t, DB.Where("provider = ? AND subject = ?", ExternalIdentityProviderTelegram, user.TelegramId). + First(&claim).Error) + assert.Equal(t, user.Id, claim.UserId) +} + +func TestInitializeExternalIdentityClaimsRejectsAmbiguousLegacyBindings(t *testing.T) { + truncateTables(t) + + first := User{Username: "telegram-legacy-one", Password: "password", TelegramId: "duplicate-telegram-id", AffCode: "telegram-legacy-one"} + second := User{Username: "telegram-legacy-two", Password: "password", TelegramId: "duplicate-telegram-id", AffCode: "telegram-legacy-two"} + require.NoError(t, DB.Create(&first).Error) + require.NoError(t, DB.Create(&second).Error) + + err := InitializeExternalIdentityClaims() + assert.ErrorIs(t, err, ErrExternalIdentityAlreadyClaimed) + + var count int64 + require.NoError(t, DB.Model(&ExternalIdentityClaim{}).Count(&count).Error) + assert.Zero(t, count) +} diff --git a/model/main.go b/model/main.go index 76f98a59c307..ac63d1f59c12 100644 --- a/model/main.go +++ b/model/main.go @@ -272,6 +272,9 @@ func migrateDB() error { &Channel{}, &Token{}, &User{}, + &UserSession{}, + &AuthFlow{}, + &ExternalIdentityClaim{}, &PasskeyCredential{}, &Option{}, &Redemption{}, @@ -303,6 +306,12 @@ func migrateDB() error { if err != nil { return err } + if err := InitializeUserAuthVersions(); err != nil { + return err + } + if err := InitializeExternalIdentityClaims(); err != nil { + return err + } if common.UsingMainDatabase(common.DatabaseTypeSQLite) { if err := ensureSubscriptionPlanTableSQLite(); err != nil { return err @@ -326,6 +335,9 @@ func migrateDBFast() error { {&Channel{}, "Channel"}, {&Token{}, "Token"}, {&User{}, "User"}, + {&UserSession{}, "UserSession"}, + {&AuthFlow{}, "AuthFlow"}, + {&ExternalIdentityClaim{}, "ExternalIdentityClaim"}, {&PasskeyCredential{}, "PasskeyCredential"}, {&Option{}, "Option"}, {&Redemption{}, "Redemption"}, @@ -375,6 +387,12 @@ func migrateDBFast() error { return err } } + if err := InitializeUserAuthVersions(); err != nil { + return err + } + if err := InitializeExternalIdentityClaims(); err != nil { + return err + } if common.UsingMainDatabase(common.DatabaseTypeSQLite) { if err := ensureSubscriptionPlanTableSQLite(); err != nil { return err diff --git a/model/passkey.go b/model/passkey.go index 5d2595cf8aaa..5bb24d404592 100644 --- a/model/passkey.go +++ b/model/passkey.go @@ -2,7 +2,6 @@ package model import ( "encoding/base64" - "encoding/json" "errors" "fmt" "strings" @@ -46,7 +45,7 @@ func (p *PasskeyCredential) TransportList() []protocol.AuthenticatorTransport { return nil } var transports []string - if err := json.Unmarshal([]byte(p.Transports), &transports); err != nil { + if err := common.Unmarshal([]byte(p.Transports), &transports); err != nil { return nil } result := make([]protocol.AuthenticatorTransport, 0, len(transports)) @@ -65,7 +64,7 @@ func (p *PasskeyCredential) SetTransports(list []protocol.AuthenticatorTransport for i, transport := range list { stringList[i] = string(transport) } - encoded, err := json.Marshal(stringList) + encoded, err := common.Marshal(stringList) if err != nil { return } @@ -121,24 +120,6 @@ func NewPasskeyCredentialFromWebAuthn(userID int, credential *webauthn.Credentia return passkey } -func (p *PasskeyCredential) ApplyValidatedCredential(credential *webauthn.Credential) { - if credential == nil || p == nil { - return - } - p.CredentialID = base64.StdEncoding.EncodeToString(credential.ID) - p.PublicKey = base64.StdEncoding.EncodeToString(credential.PublicKey) - p.AttestationType = credential.AttestationType - p.AAGUID = base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID) - p.SignCount = credential.Authenticator.SignCount - p.CloneWarning = credential.Authenticator.CloneWarning - p.UserPresent = credential.Flags.UserPresent - p.UserVerified = credential.Flags.UserVerified - p.BackupEligible = credential.Flags.BackupEligible - p.BackupState = credential.Flags.BackupState - p.Attachment = string(credential.Authenticator.Attachment) - p.SetTransports(credential.Transport) -} - func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) { if userID == 0 { common.SysLog("GetPasskeyByUserID: empty user ID") @@ -177,34 +158,88 @@ func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) { return &credential, nil } -func UpsertPasskeyCredential(credential *PasskeyCredential) error { - if credential == nil { - common.SysLog("UpsertPasskeyCredential: nil credential provided") +// UpdatePasskeyAssertionState persists only fields produced by a successful +// assertion. Registration identity (credential ID, public key, AAGUID, +// transports and attestation metadata) is immutable on this path. +func UpdatePasskeyAssertionState(userID int, credential *webauthn.Credential, lastUsedAt time.Time) error { + if userID <= 0 || credential == nil || len(credential.ID) == 0 || lastUsedAt.IsZero() { return fmt.Errorf("Passkey 保存失败,请重试") } - return DB.Transaction(func(tx *gorm.DB) error { - // 使用Unscoped()进行硬删除,避免唯一索引冲突 - if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil { - common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err)) - return fmt.Errorf("Passkey 保存失败,请重试") - } - if err := tx.Create(credential).Error; err != nil { - common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err)) - return fmt.Errorf("Passkey 保存失败,请重试") + credentialID := base64.StdEncoding.EncodeToString(credential.ID) + result := DB.Model(&PasskeyCredential{}). + Where("user_id = ? AND credential_id = ?", userID, credentialID). + Updates(map[string]interface{}{ + "sign_count": credential.Authenticator.SignCount, + "clone_warning": credential.Authenticator.CloneWarning, + "user_present": credential.Flags.UserPresent, + "user_verified": credential.Flags.UserVerified, + "backup_eligible": credential.Flags.BackupEligible, + "backup_state": credential.Flags.BackupState, + "last_used_at": lastUsedAt, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrPasskeyNotFound + } + return nil +} + +func upsertPasskeyCredentialWithTx(tx *gorm.DB, credential *PasskeyCredential) error { + if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil { + common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err)) + return fmt.Errorf("Passkey 保存失败,请重试") + } + if err := tx.Create(credential).Error; err != nil { + common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err)) + return fmt.Errorf("Passkey 保存失败,请重试") + } + return nil +} + +// UpsertPasskeyCredentialWithAuthVersion is reserved for enrollment changes; +// assertion sign-count updates must use UpdatePasskeyAssertionState. +func UpsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential) error { + if credential == nil || credential.UserID <= 0 { + return fmt.Errorf("Passkey 保存失败,请重试") + } + if err := DB.Transaction(func(tx *gorm.DB) error { + if _, err := IncrementUserAuthVersionWithTx(tx, credential.UserID); err != nil { + return err } - return nil - }) + return upsertPasskeyCredentialWithTx(tx, credential) + }); err != nil { + return err + } + return PublishUserAuthCache(credential.UserID) } -func DeletePasskeyByUserID(userID int) error { +func DeletePasskeyByUserIDWithAuthVersion(userID int) error { if userID == 0 { - common.SysLog("DeletePasskeyByUserID: empty user ID") return fmt.Errorf("删除失败,请重试") } - // 使用Unscoped()进行硬删除,避免唯一索引冲突 - if err := DB.Unscoped().Where("user_id = ?", userID).Delete(&PasskeyCredential{}).Error; err != nil { - common.SysLog(fmt.Sprintf("DeletePasskeyByUserID: failed to delete passkey for user %d: %v", userID, err)) - return fmt.Errorf("删除失败,请重试") + if err := DB.Transaction(func(tx *gorm.DB) error { + var credential PasskeyCredential + if err := lockForUpdate(tx).Where("user_id = ?", userID).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrPasskeyNotFound + } + return err + } + if _, err := IncrementUserAuthVersionWithTx(tx, userID); err != nil { + return err + } + result := tx.Unscoped().Delete(&credential) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrPasskeyNotFound + } + return nil + }); err != nil { + return err } - return nil + return PublishUserAuthCache(userID) } diff --git a/model/subscription.go b/model/subscription.go index 642c7b0355c6..497fea147a80 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -431,7 +431,7 @@ func getUserGroupByIdTx(tx *gorm.DB, userId int) (string, error) { tx = DB } var group string - if err := tx.Model(&User{}).Where("id = ?", userId).Select(commonGroupCol).Find(&group).Error; err != nil { + if err := lockForUpdate(tx).Model(&User{}).Where("id = ?", userId).Select(commonGroupCol).Find(&group).Error; err != nil { return "", err } return group, nil @@ -557,6 +557,12 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio return sub, nil } +func refreshSubscriptionUserGroupCache(userId int, operation string) { + if err := RefreshUserGroupCache(userId); err != nil { + common.SysError(fmt.Sprintf("failed to refresh user group cache after %s for user %d: %v", operation, userId, err)) + } +} + // Complete a subscription order (idempotent). Creates a UserSubscription snapshot from the plan. // expectedPaymentProvider guards against cross-gateway callback attacks (empty skips the check). // actualPaymentMethod updates the order's PaymentMethod to reflect the real payment type used (empty skips update). @@ -594,11 +600,13 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP if !plan.Enabled { // still allow completion for already purchased orders } - upgradeGroup = strings.TrimSpace(plan.UpgradeGroup) - _, err = CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order") + subscription, err := CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order") if err != nil { return err } + if subscription.PrevUserGroup != "" { + upgradeGroup = strings.TrimSpace(subscription.UpgradeGroup) + } if err := upsertSubscriptionTopUpTx(tx, &order); err != nil { return err } @@ -623,7 +631,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP return err } if upgradeGroup != "" && logUserId > 0 { - _ = UpdateUserGroupCache(logUserId, upgradeGroup) + refreshSubscriptionUserGroupCache(logUserId, "subscription payment completion") } if logUserId > 0 { msg := fmt.Sprintf("订阅购买成功,套餐: %s,支付金额: %.2f,支付方式: %s", logPlanTitle, logMoney, logPaymentMethod) @@ -702,15 +710,19 @@ func AdminBindSubscription(userId int, planId int, sourceNote string) (string, e if err != nil { return "", err } + groupChanged := false err = DB.Transaction(func(tx *gorm.DB) error { - _, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "admin") + subscription, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "admin") + if err == nil { + groupChanged = subscription.PrevUserGroup != "" + } return err }) if err != nil { return "", err } - if strings.TrimSpace(plan.UpgradeGroup) != "" { - _ = UpdateUserGroupCache(userId, plan.UpgradeGroup) + if groupChanged { + refreshSubscriptionUserGroupCache(userId, "admin subscription creation") return fmt.Sprintf("用户分组将升级到 %s", plan.UpgradeGroup), nil } return "", nil @@ -774,7 +786,8 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error { } } - if _, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, PaymentMethodBalance); err != nil { + subscription, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, PaymentMethodBalance) + if err != nil { return err } @@ -799,7 +812,9 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error { logPlanTitle = plan.Title logMoney = plan.PriceAmount chargedQuota = requiredQuota - upgradeGroup = strings.TrimSpace(plan.UpgradeGroup) + if subscription.PrevUserGroup != "" { + upgradeGroup = strings.TrimSpace(subscription.UpgradeGroup) + } return nil }) if err != nil { @@ -812,7 +827,7 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error { } } if upgradeGroup != "" { - _ = UpdateUserGroupCache(userId, upgradeGroup) + refreshSubscriptionUserGroupCache(userId, "subscription balance purchase") } msg := fmt.Sprintf("使用余额购买订阅成功,套餐: %s,支付金额: %.2f,扣除额度: %d", logPlanTitle, logMoney, chargedQuota) RecordLog(userId, LogTypeTopup, msg) @@ -935,7 +950,7 @@ func AdminInvalidateUserSubscription(userSubscriptionId int) (string, error) { return "", err } if cacheGroup != "" && userId > 0 { - _ = UpdateUserGroupCache(userId, cacheGroup) + refreshSubscriptionUserGroupCache(userId, "admin subscription update") } if downgradeGroup != "" { return fmt.Sprintf("用户分组将回退到 %s", downgradeGroup), nil @@ -976,7 +991,7 @@ func AdminDeleteUserSubscription(userSubscriptionId int) (string, error) { return "", err } if cacheGroup != "" && userId > 0 { - _ = UpdateUserGroupCache(userId, cacheGroup) + refreshSubscriptionUserGroupCache(userId, "admin subscription deletion") } if downgradeGroup != "" { return fmt.Sprintf("用户分组将回退到 %s", downgradeGroup), nil @@ -1203,7 +1218,7 @@ func ExpireDueSubscriptions(limit int) (int, error) { return expiredCount, err } if cacheGroup != "" { - _ = UpdateUserGroupCache(userId, cacheGroup) + refreshSubscriptionUserGroupCache(userId, "subscription expiration") } } return expiredCount, nil diff --git a/model/subscription_auth_test.go b/model/subscription_auth_test.go new file mode 100644 index 000000000000..d9848c971519 --- /dev/null +++ b/model/subscription_auth_test.go @@ -0,0 +1,150 @@ +package model + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/glebarez/sqlite" + "github.com/go-redis/redis/v8" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestSubscriptionGroupTransitionsPreserveAuthVersionAndSessions(t *testing.T) { + truncateTables(t) + useUserCacheMiniRedis(t) + now := time.Now().Unix() + user := User{ + Username: "subscription-auth-user", + Password: "unused-password-hash", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + AuthVersion: 1, + } + require.NoError(t, DB.Create(&user).Error) + require.NoError(t, CreateUserSession(&UserSession{ + SID: "subscription-auth-session", + UserID: user.Id, + Version: 1, + UserAuthVersion: 1, + Status: UserSessionStatusActive, + RefreshHash: "refresh-hash", + LoginMethod: "password", + LastActiveAt: now, + ExpiresAt: now + 3600, + })) + require.NoError(t, populateUserCache(user)) + plan := &SubscriptionPlan{ + Title: "Upgraded", + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 100, + UpgradeGroup: "pro", + Enabled: true, + } + require.NoError(t, DB.Create(plan).Error) + + subscription, err := CreateUserSubscriptionFromPlanTx(DB, user.Id, plan, "test") + require.NoError(t, err) + require.Equal(t, "default", subscription.PrevUserGroup) + require.NoError(t, RefreshUserGroupCache(user.Id)) + + var updated User + require.NoError(t, DB.First(&updated, user.Id).Error) + assert.Equal(t, "pro", updated.Group) + assert.EqualValues(t, 1, updated.AuthVersion) + var session UserSession + require.NoError(t, DB.First(&session, "sid = ?", "subscription-auth-session").Error) + assert.Equal(t, UserSessionStatusActive, session.Status) + cached, err := GetUserCache(user.Id) + require.NoError(t, err) + assert.Equal(t, "pro", cached.Group) + assert.EqualValues(t, 1, cached.AuthVersion) + + require.NoError(t, DB.Transaction(func(tx *gorm.DB) error { + target, err := downgradeUserGroupForSubscriptionTx(tx, subscription, now+1) + assert.Equal(t, "default", target) + return err + })) + require.NoError(t, RefreshUserGroupCache(user.Id)) + require.NoError(t, DB.First(&updated, user.Id).Error) + assert.Equal(t, "default", updated.Group) + assert.EqualValues(t, 1, updated.AuthVersion) + require.NoError(t, DB.First(&session, "sid = ?", "subscription-auth-session").Error) + assert.Equal(t, UserSessionStatusActive, session.Status) + cached, err = GetUserCache(user.Id) + require.NoError(t, err) + assert.Equal(t, "default", cached.Group) +} + +func TestSubscriptionGroupCacheRefreshFailureDoesNotChangeCommittedResult(t *testing.T) { + previousDB, previousLogDB := DB, LOG_DB + previousMainDatabaseType, previousLogDatabaseType := common.MainDatabaseType(), common.LogDatabaseType() + common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite) + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + DB, LOG_DB = db, db + require.NoError(t, db.AutoMigrate(&User{}, &SubscriptionPlan{}, &UserSubscription{})) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(4) + t.Cleanup(func() { + DB, LOG_DB = previousDB, previousLogDB + common.SetDatabaseTypes(previousMainDatabaseType, previousLogDatabaseType) + _ = sqlDB.Close() + }) + + user := User{ + Username: "subscription-cache-failure", + Password: "unused-password-hash", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + AuthVersion: 1, + } + require.NoError(t, DB.Create(&user).Error) + plan := &SubscriptionPlan{ + Title: "Cache failure plan", + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 100, + UpgradeGroup: "pro", + Enabled: true, + } + require.NoError(t, DB.Create(plan).Error) + InvalidateSubscriptionPlanCache(plan.Id) + + oldRedisEnabled, oldRDB := common.RedisEnabled, common.RDB + common.RedisEnabled = true + common.RDB = redis.NewClient(&redis.Options{ + Dialer: func(context.Context, string, string) (net.Conn, error) { + return nil, errors.New("forced redis failure") + }, + MaxRetries: -1, + }) + t.Cleanup(func() { + _ = common.RDB.Close() + common.RedisEnabled, common.RDB = oldRedisEnabled, oldRDB + }) + + message, err := AdminBindSubscription(user.Id, plan.Id, "test") + require.NoError(t, err) + assert.Contains(t, message, "pro") + + var updated User + require.NoError(t, DB.First(&updated, user.Id).Error) + assert.Equal(t, "pro", updated.Group) + assert.EqualValues(t, 1, updated.AuthVersion) + var subscription UserSubscription + require.NoError(t, DB.Where("user_id = ?", user.Id).First(&subscription).Error) + assert.Equal(t, "active", subscription.Status) +} diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 91ca28fce81b..a53804d3baab 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -37,6 +37,9 @@ func TestMain(m *testing.M) { if err := db.AutoMigrate( &Task{}, &User{}, + &UserSession{}, + &AuthFlow{}, + &ExternalIdentityClaim{}, &Token{}, &PasskeyCredential{}, &TwoFA{}, @@ -65,6 +68,9 @@ func truncateTables(t *testing.T) { t.Helper() t.Cleanup(func() { DB.Exec("DELETE FROM tasks") + DB.Exec("DELETE FROM auth_flows") + DB.Exec("DELETE FROM external_identity_claims") + DB.Exec("DELETE FROM user_sessions") DB.Exec("DELETE FROM passkey_credentials") DB.Exec("DELETE FROM two_fa_backup_codes") DB.Exec("DELETE FROM two_fas") diff --git a/model/twofa.go b/model/twofa.go index 1887bfe56867..9f2f1f1b8458 100644 --- a/model/twofa.go +++ b/model/twofa.go @@ -62,8 +62,12 @@ func IsTwoFAEnabled(userId int) (bool, error) { return twoFA != nil && twoFA.IsEnabled, nil } -// CreateTwoFA 创建2FA设置 -func (t *TwoFA) Create() error { +// CreatePendingTwoFASetup stores a disabled factor while the user completes +// enrollment. Enabling a factor must use EnableWithAuthVersion. +func (t *TwoFA) CreatePendingTwoFASetup() error { + if t == nil || t.UserId <= 0 || t.IsEnabled { + return errors.New("无效的2FA待验证设置") + } // 检查用户是否已存在2FA设置 existing, err := GetTwoFAByUserId(t.UserId) if err != nil { @@ -85,29 +89,35 @@ func (t *TwoFA) Create() error { return DB.Create(t).Error } -// Update 更新2FA设置 -func (t *TwoFA) Update() error { +func (t *TwoFA) updateUsageState() error { if t.Id == 0 { return errors.New("2FA记录ID不能为空") } - return DB.Save(t).Error + return DB.Model(&TwoFA{}).Where("id = ?", t.Id).Updates(map[string]interface{}{ + "failed_attempts": t.FailedAttempts, + "locked_until": t.LockedUntil, + "last_used_at": t.LastUsedAt, + }).Error } -// Delete 删除2FA设置 -func (t *TwoFA) Delete() error { - if t.Id == 0 { +// DeletePendingTwoFASetup removes only an unverified setup. Enabled factors +// must use DisableTwoFAWithAuthVersion. +func (t *TwoFA) DeletePendingTwoFASetup() error { + if t == nil || t.Id == 0 || t.UserId <= 0 { return errors.New("2FA记录ID不能为空") } - // 使用事务确保原子性 return DB.Transaction(func(tx *gorm.DB) error { - // 同时删除相关的备用码记录(硬删除) + var pending TwoFA + if err := lockForUpdate(tx). + Where("id = ? AND user_id = ? AND is_enabled = ?", t.Id, t.UserId, false). + First(&pending).Error; err != nil { + return err + } if err := tx.Unscoped().Where("user_id = ?", t.UserId).Delete(&TwoFABackupCode{}).Error; err != nil { return err } - - // 硬删除2FA记录 - return tx.Unscoped().Delete(t).Error + return tx.Unscoped().Delete(&pending).Error }) } @@ -115,7 +125,7 @@ func (t *TwoFA) Delete() error { func (t *TwoFA) ResetFailedAttempts() error { t.FailedAttempts = 0 t.LockedUntil = nil - return t.Update() + return t.updateUsageState() } // IncrementFailedAttempts 增加失败尝试次数 @@ -174,34 +184,53 @@ func (t *TwoFA) IsLocked() bool { return time.Now().Before(*t.LockedUntil) } -// CreateBackupCodes 创建备用码 -func CreateBackupCodes(userId int, codes []string) error { +// CreatePendingTwoFASetupBackupCodes stores recovery codes for an unverified +// setup. Regeneration for an enabled factor must advance auth_version. +func CreatePendingTwoFASetupBackupCodes(userId int, codes []string) error { return DB.Transaction(func(tx *gorm.DB) error { - // 先删除现有的备用码 - if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil { + var pending TwoFA + if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, false).First(&pending).Error; err != nil { return err } + return replaceBackupCodesWithTx(tx, userId, codes) + }) +} - // 创建新的备用码记录 - for _, code := range codes { - hashedCode, err := common.HashBackupCode(code) - if err != nil { - return err - } - - backupCode := TwoFABackupCode{ - UserId: userId, - CodeHash: hashedCode, - IsUsed: false, - } +func replaceBackupCodesWithTx(tx *gorm.DB, userId int, codes []string) error { + if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil { + return err + } + for _, code := range codes { + hashedCode, err := common.HashBackupCode(code) + if err != nil { + return err + } + if err := tx.Create(&TwoFABackupCode{UserId: userId, CodeHash: hashedCode, IsUsed: false}).Error; err != nil { + return err + } + } + return nil +} - if err := tx.Create(&backupCode).Error; err != nil { - return err +// ReplaceBackupCodesWithAuthVersion atomically replaces the factor's recovery +// credentials and advances the user's authentication version. +func ReplaceBackupCodesWithAuthVersion(userId int, codes []string) error { + if err := DB.Transaction(func(tx *gorm.DB) error { + var enabled TwoFA + if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, true).First(&enabled).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrTwoFANotEnabled } + return err } - - return nil - }) + if _, err := IncrementUserAuthVersionWithTx(tx, userId); err != nil { + return err + } + return replaceBackupCodesWithTx(tx, userId, codes) + }); err != nil { + return err + } + return PublishUserAuthCache(userId) } // ValidateBackupCode 验证并使用备用码 @@ -245,26 +274,63 @@ func GetUnusedBackupCodeCount(userId int) (int, error) { return int(count), err } -// DisableTwoFA 禁用用户的2FA -func DisableTwoFA(userId int) error { - twoFA, err := GetTwoFAByUserId(userId) - if err != nil { +// DisableTwoFAWithAuthVersion atomically removes the factor and invalidates +// every access token issued against the previous security configuration. +func DisableTwoFAWithAuthVersion(userId int) error { + if err := DB.Transaction(func(tx *gorm.DB) error { + var twoFA TwoFA + if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, true).First(&twoFA).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrTwoFANotEnabled + } + return err + } + if _, err := IncrementUserAuthVersionWithTx(tx, userId); err != nil { + return err + } + if err := tx.Unscoped().Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil { + return err + } + return tx.Unscoped().Delete(&twoFA).Error + }); err != nil { return err } - if twoFA == nil { - return ErrTwoFANotEnabled - } - - // 删除2FA设置和备用码 - return twoFA.Delete() + return PublishUserAuthCache(userId) } -// EnableTwoFA 启用2FA -func (t *TwoFA) Enable() error { +// EnableWithAuthVersion atomically enables this factor and advances the user +// authentication version so pre-enrollment sessions cannot remain valid. +func (t *TwoFA) EnableWithAuthVersion() error { + if t == nil || t.Id == 0 || t.UserId == 0 { + return errors.New("2FA记录ID不能为空") + } + if err := DB.Transaction(func(tx *gorm.DB) error { + var pending TwoFA + if err := lockForUpdate(tx).Where("id = ? AND user_id = ? AND is_enabled = ?", t.Id, t.UserId, false).First(&pending).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrTwoFAAlreadyEnabled + } + return err + } + if _, err := IncrementUserAuthVersionWithTx(tx, t.UserId); err != nil { + return err + } + result := tx.Model(&pending). + Updates(map[string]interface{}{"is_enabled": true, "failed_attempts": 0, "locked_until": nil}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrTwoFAAlreadyEnabled + } + return nil + }); err != nil { + return err + } t.IsEnabled = true t.FailedAttempts = 0 t.LockedUntil = nil - return t.Update() + return PublishUserAuthCache(t.UserId) } // ValidateTOTPAndUpdateUsage 验证TOTP并更新使用记录 @@ -289,7 +355,7 @@ func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) { t.LockedUntil = nil t.LastUsedAt = &now - if err := t.Update(); err != nil { + if err := t.updateUsageState(); err != nil { common.SysLog("更新2FA使用记录失败: " + err.Error()) } @@ -323,7 +389,7 @@ func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) { t.LockedUntil = nil t.LastUsedAt = &now - if err := t.Update(); err != nil { + if err := t.updateUsageState(); err != nil { common.SysLog("更新2FA使用记录失败: " + err.Error()) } diff --git a/model/user.go b/model/user.go index 3a33c82f691b..75531a26c960 100644 --- a/model/user.go +++ b/model/user.go @@ -108,18 +108,22 @@ type User struct { StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"` + AuthVersion int64 `json:"-" gorm:"type:bigint;not null;default:1;column:auth_version"` AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"` } func (user *User) ToBaseUser() *UserBase { cache := &UserBase{ - Id: user.Id, - Group: user.Group, - Quota: user.Quota, - Status: user.Status, - Username: user.Username, - Setting: user.Setting, - Email: user.Email, + Id: user.Id, + Group: user.Group, + Quota: user.Quota, + Status: user.Status, + Role: user.Role, + Username: user.Username, + Setting: user.Setting, + Email: user.Email, + AuthVersion: user.AuthVersion, + CacheSchema: userCacheSchemaVersion, } return cache } @@ -699,10 +703,23 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) { } func (user *User) Update(updatePassword bool) error { - if err := user.UpdateWithTx(DB, updatePassword); err != nil { + var previousAuthVersion int64 + if err := DB.Model(&User{}).Where("id = ?", user.Id).Select("auth_version").Find(&previousAuthVersion).Error; err != nil { return err } - return updateUserCache(*user) + if err := DB.Transaction(func(tx *gorm.DB) error { + return user.UpdateWithTx(tx, updatePassword) + }); err != nil { + return err + } + if err := updateUserCache(*user); err != nil { + return err + } + if user.AuthVersion > previousAuthVersion { + _, err := RevokeAllUserSessions(user.Id, "user_security_changed") + return err + } + return nil } func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error { @@ -718,17 +735,43 @@ func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error { if err = tx.First(¤t, user.Id).Error; err != nil { return err } - if err = tx.Model(¤t).Omit("quota", "used_quota", "request_count").Updates(newUser).Error; err != nil { + // Updates(struct) ignores zero values. Match that behavior when deciding + // whether this request actually changes authentication-sensitive state; + // partial self-profile updates intentionally leave role/status/group empty. + authChanged := (updatePassword && current.Password != newUser.Password) || + (newUser.Role != 0 && current.Role != newUser.Role) || + (newUser.Status != 0 && current.Status != newUser.Status) || + (newUser.Group != "" && current.Group != newUser.Group) + if authChanged { + newUser.AuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id) + if err != nil { + return err + } + } + if err = tx.Model(¤t).Omit("quota", "used_quota", "request_count", "auth_version").Updates(newUser).Error; err != nil { return err } return tx.First(user, user.Id).Error } func (user *User) Edit(updatePassword bool) error { - if err := user.EditWithTx(DB, updatePassword); err != nil { + var previousAuthVersion int64 + if err := DB.Model(&User{}).Where("id = ?", user.Id).Select("auth_version").Find(&previousAuthVersion).Error; err != nil { return err } - return updateUserCache(*user) + if err := DB.Transaction(func(tx *gorm.DB) error { + return user.EditWithTx(tx, updatePassword) + }); err != nil { + return err + } + if err := updateUserCache(*user); err != nil { + return err + } + if user.AuthVersion > previousAuthVersion { + _, err := RevokeAllUserSessions(user.Id, "user_security_changed") + return err + } + return nil } func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error { @@ -755,6 +798,13 @@ func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error { if err = tx.First(¤t, user.Id).Error; err != nil { return err } + authChanged := (updatePassword && current.Password != newUser.Password) || current.Group != newUser.Group + if authChanged { + newUser.AuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id) + if err != nil { + return err + } + } if err = tx.Model(¤t).Updates(updates).Error; err != nil { return err } @@ -781,7 +831,15 @@ func (user *User) ClearBinding(bindingType string) error { return errors.New("invalid binding type") } - if err := DB.Model(&User{}).Where("id = ?", user.Id).Update(column, "").Error; err != nil { + if err := DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&User{}).Where("id = ?", user.Id).Update(column, "").Error; err != nil { + return err + } + if bindingType == ExternalIdentityProviderTelegram { + return ReleaseExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.Id) + } + return nil + }); err != nil { return err } @@ -796,11 +854,23 @@ func (user *User) Delete() error { if user.Id == 0 { return errors.New("id 为空!") } - if err := DB.Delete(user).Error; err != nil { + var nextAuthVersion int64 + if err := DB.Transaction(func(tx *gorm.DB) error { + var err error + nextAuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id) + if err != nil { + return err + } + return tx.Delete(user).Error + }); err != nil { + return err + } + if err := publishCommittedUserAuthVersion(user.Id, nextAuthVersion); err != nil { + return err + } + if _, err := RevokeAllUserSessions(user.Id, "user_deleted"); err != nil { return err } - - // 清除缓存 return invalidateUserCache(user.Id) } @@ -809,7 +879,13 @@ func (user *User) HardDelete() error { return errors.New("id 为空!") } var tokens []Token + var deletedAuthVersion int64 err := DB.Transaction(func(tx *gorm.DB) error { + var err error + deletedAuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id) + if err != nil { + return err + } if common.RedisEnabled { if err := tx.Unscoped().Select("id", commonKeyCol).Where("user_id = ?", user.Id).Find(&tokens).Error; err != nil { return err @@ -823,6 +899,9 @@ func (user *User) HardDelete() error { if err != nil { return err } + if err := publishCommittedUserAuthVersion(user.Id, deletedAuthVersion); err != nil { + common.SysError(fmt.Sprintf("failed to publish auth tombstone after hard deleting user %d: %v", user.Id, err)) + } if err := invalidateTokensCache(tokens); err != nil { common.SysError(fmt.Sprintf("failed to invalidate token cache after hard deleting user %d: %v", user.Id, err)) } @@ -833,9 +912,14 @@ func (user *User) HardDelete() error { } func deleteUserAuthenticationData(tx *gorm.DB, userId int) error { + if err := releaseAllExternalIdentitiesWithTx(tx, userId); err != nil { + return err + } for _, authenticationData := range []any{ &TwoFABackupCode{}, &TwoFA{}, + &UserSession{}, + &AuthFlow{}, &PasskeyCredential{}, &Token{}, } { @@ -997,7 +1081,18 @@ func ResetUserPasswordByEmail(email string, password string) error { if err != nil { return err } - err = DB.Model(&User{}).Where("id = ?", user.Id).Update("password", hashedPassword).Error + if err = DB.Transaction(func(tx *gorm.DB) error { + if _, err := IncrementUserAuthVersionWithTx(tx, user.Id); err != nil { + return err + } + return tx.Model(&User{}).Where("id = ?", user.Id).Update("password", hashedPassword).Error + }); err != nil { + return err + } + if err := PublishUserAuthCache(user.Id); err != nil { + return err + } + _, err = RevokeAllUserSessions(user.Id, "password_reset") return err } @@ -1074,7 +1169,7 @@ func GetUserGroup(id int, fromDB bool) (group string, err error) { // Update Redis cache asynchronously on successful DB read if shouldUpdateRedis(fromDB, err) { gopool.Go(func() { - if err := updateUserGroupCache(id, group); err != nil { + if err := RefreshUserGroupCache(id); err != nil { common.SysLog("failed to update user group cache: " + err.Error()) } }) diff --git a/model/user_auth_cache.go b/model/user_auth_cache.go new file mode 100644 index 000000000000..d2cdc8a7cc4d --- /dev/null +++ b/model/user_auth_cache.go @@ -0,0 +1,283 @@ +package model + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" +) + +// User auth cache fencing uses three Redis keys per user: the cached user +// hash, a short-lived pending fence published before a restrictive database +// transaction, and a monotonic committed version floor published after +// commit. Cache writes below either floor are rejected, readers below the +// effective floor fall back to the database, and the pending fence outlives +// every user-hash TTL so a rolled-back transaction heals without allowing a +// stale snapshot to re-authorize the user. + +var ErrUserAuthCachePending = errors.New("user authentication state update is pending") + +var ErrUserAuthVersionConflict = errors.New("user authentication version update conflicted") + +func getUserAuthFenceKey(userId int) string { + return fmt.Sprintf("auth:user:fence:%d", userId) +} + +func getUserAuthVersionKey(userId int) string { + return fmt.Sprintf("auth:user:version:%d", userId) +} + +// A pending fence only covers the interval between publishing the next +// version and the surrounding database transaction reaching a decision. Its +// TTL must outlive every user hash that could have been populated before the +// fence, while still allowing an automatically rolled-back transaction to +// recover without an operator repairing Redis. +func userAuthFenceTTLSeconds() int { + cacheTTL := userCacheTTLSeconds() + extra := cacheTTL + if extra < 60 { + extra = 60 + } + return cacheTTL + extra +} + +func writeUserCache(user *UserBase, includeQuota bool) error { + if user == nil || user.Id <= 0 || !common.RedisEnabled { + return nil + } + user.CacheSchema = userCacheSchemaVersion + if user.AuthVersion <= 0 { + return fmt.Errorf("invalid user auth version") + } + includeQuotaArg := "0" + if includeQuota { + includeQuotaArg = "1" + } + ttl := userCacheTTLSeconds() + const script = ` +local incoming = tonumber(ARGV[1]) +local pending = tonumber(redis.call('GET', KEYS[2]) or '0') +local committed = tonumber(redis.call('GET', KEYS[3]) or '0') +local current = tonumber(redis.call('HGET', KEYS[1], 'AuthVersion') or '0') +if pending > incoming or committed > incoming or current > incoming then + return 0 +end +if committed < incoming then + redis.call('SET', KEYS[3], ARGV[1]) +end +if pending > 0 and pending <= incoming then + redis.call('DEL', KEYS[2]) +end +if ARGV[10] == '0' and redis.call('EXISTS', KEYS[1]) == 0 then + return 1 +end +redis.call('HSET', KEYS[1], + 'Id', ARGV[2], 'Group', ARGV[3], 'Email', ARGV[4], + 'Status', ARGV[5], 'Role', ARGV[6], 'Username', ARGV[7], + 'Setting', ARGV[8], 'AuthVersion', ARGV[1], 'CacheSchema', ARGV[9]) +if ARGV[10] == '1' and redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then + redis.call('HSET', KEYS[1], 'Quota', ARGV[11]) +end +redis.call('EXPIRE', KEYS[1], ARGV[12]) +return 1` + result, err := common.RDB.Eval(context.Background(), script, + []string{getUserCacheKey(user.Id), getUserAuthFenceKey(user.Id), getUserAuthVersionKey(user.Id)}, + user.AuthVersion, user.Id, user.Group, user.Email, user.Status, user.Role, + user.Username, user.Setting, user.CacheSchema, includeQuotaArg, user.Quota, ttl, + ).Int() + if err != nil { + return err + } + if result == 0 { + return ErrUserAuthCachePending + } + return nil +} + +func getUserAuthVersionFloor(userId int) (int64, error) { + if !common.RedisEnabled { + return 0, nil + } + values, err := common.RDB.MGet(context.Background(), getUserAuthFenceKey(userId), getUserAuthVersionKey(userId)).Result() + if err != nil { + return 0, err + } + var floor int64 + for _, value := range values { + if value == nil { + continue + } + parsed, err := strconv.ParseInt(fmt.Sprint(value), 10, 64) + if err != nil { + return 0, err + } + if parsed > floor { + floor = parsed + } + } + return floor, nil +} + +// SetUserAuthVersionFence publishes a fail-closed version before a restrictive +// database update. Pending fences expire only after every pre-existing user +// hash must have expired; a committed update is promoted separately to a +// permanent monotonic version floor. +func SetUserAuthVersionFence(userId int, authVersion int64) error { + if !common.RedisEnabled { + return nil + } + if userId <= 0 || authVersion <= 0 { + return fmt.Errorf("invalid user auth fence") + } + const script = ` +local current = tonumber(redis.call('GET', KEYS[1]) or '0') +local incoming = tonumber(ARGV[1]) +if current < incoming then + redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2]) +elseif current == incoming then + redis.call('EXPIRE', KEYS[1], ARGV[2]) +elseif redis.call('TTL', KEYS[1]) < 0 then + redis.call('EXPIRE', KEYS[1], ARGV[2]) +end +return 1` + return common.RDB.Eval(context.Background(), script, []string{getUserAuthFenceKey(userId)}, authVersion, userAuthFenceTTLSeconds()).Err() +} + +// publishCommittedUserAuthVersion records the durable lower bound used to +// reject an arbitrarily delayed cache fill after a committed security change. +// It also removes this transaction's now-obsolete pending fence. +func publishCommittedUserAuthVersion(userId int, authVersion int64) error { + if !common.RedisEnabled { + return nil + } + if userId <= 0 || authVersion <= 0 { + return fmt.Errorf("invalid committed user auth version") + } + const script = ` +local incoming = tonumber(ARGV[1]) +local committed = tonumber(redis.call('GET', KEYS[1]) or '0') +local pending = tonumber(redis.call('GET', KEYS[2]) or '0') +if committed < incoming then + redis.call('SET', KEYS[1], ARGV[1]) +end +if pending > 0 and pending <= incoming then + redis.call('DEL', KEYS[2]) +end +return 1` + return common.RDB.Eval(context.Background(), script, + []string{getUserAuthVersionKey(userId), getUserAuthFenceKey(userId)}, authVersion, + ).Err() +} + +// IncrementUserAuthVersionWithTx locks the user, publishes the next deny +// fence, then persists the version in the caller's transaction. Unscoped is +// intentional so the same fail-closed path also covers hard deletion of an +// already soft-deleted user. +func IncrementUserAuthVersionWithTx(tx *gorm.DB, userId int) (int64, error) { + if tx == nil || userId <= 0 { + return 0, fmt.Errorf("invalid user auth version update") + } + for range 3 { + var user User + if err := lockForUpdate(tx.Unscoped()).Select("id", "auth_version").Where("id = ?", userId).First(&user).Error; err != nil { + return 0, err + } + current := user.AuthVersion + if current < 1 { + current = 1 + } + next := current + 1 + if err := SetUserAuthVersionFence(userId, next); err != nil { + return 0, err + } + result := tx.Unscoped().Model(&User{}). + Where("id = ? AND auth_version = ?", userId, user.AuthVersion). + Update("auth_version", next) + if result.Error != nil { + return 0, result.Error + } + if result.RowsAffected == 1 { + return next, nil + } + } + return 0, ErrUserAuthVersionConflict +} + +// BumpUserAuthVersion is the transaction-owning variant used by password, +// role, status and security-factor changes outside another transaction. +func BumpUserAuthVersion(userId int) (int64, error) { + var next int64 + if err := DB.Transaction(func(tx *gorm.DB) error { + var err error + next, err = IncrementUserAuthVersionWithTx(tx, userId) + return err + }); err != nil { + return 0, err + } + if err := PublishUserAuthCache(userId); err != nil { + return next, err + } + return next, nil +} + +// PublishUserAuthCache refreshes the current database state after a successful +// auth-sensitive transaction without touching the cached quota field. +func PublishUserAuthCache(userId int) error { + user, err := GetUserById(userId, false) + if err != nil { + return err + } + return updateUserCache(*user) +} + +// InitializeUserAuthVersions must run after AutoMigrate when upgrading an +// existing database. It is idempotent and portable across all supported DBs. +func InitializeUserAuthVersions() error { + return DB.Model(&User{}).Where("auth_version IS NULL OR auth_version < ?", 1).Update("auth_version", 1).Error +} + +func updateUserCacheFieldAtVersion(userId int, field string, value interface{}, authVersion int64) error { + if !common.RedisEnabled { + return nil + } + if userId <= 0 || authVersion <= 0 { + return fmt.Errorf("invalid user auth version") + } + const script = ` +local incoming = tonumber(ARGV[1]) +local pending = tonumber(redis.call('GET', KEYS[2]) or '0') +local committed = tonumber(redis.call('GET', KEYS[3]) or '0') +local current = tonumber(redis.call('HGET', KEYS[1], 'AuthVersion') or '0') +if pending > incoming or committed > incoming or current > incoming then + return 0 +end +if committed < incoming then + redis.call('SET', KEYS[3], ARGV[1]) +end +if pending > 0 and pending <= incoming then + redis.call('DEL', KEYS[2]) +end +if redis.call('EXISTS', KEYS[1]) == 0 then + return 1 +end +if current ~= incoming then + return 1 +end +redis.call('HSET', KEYS[1], ARGV[2], ARGV[3], 'CacheSchema', ARGV[4]) +return 1` + result, err := common.RDB.Eval(context.Background(), script, + []string{getUserCacheKey(userId), getUserAuthFenceKey(userId), getUserAuthVersionKey(userId)}, + authVersion, field, value, userCacheSchemaVersion, + ).Int() + if err != nil { + return err + } + if result == 0 { + return ErrUserAuthCachePending + } + return nil +} diff --git a/model/user_authentication_test.go b/model/user_authentication_test.go index c285e0238736..179232798d1b 100644 --- a/model/user_authentication_test.go +++ b/model/user_authentication_test.go @@ -2,38 +2,48 @@ package model import ( "context" + "encoding/base64" "errors" "net" "sync" - "sync/atomic" "testing" + "time" "github.com/QuantumNous/new-api/common" "github.com/go-redis/redis/v8" + "github.com/go-webauthn/webauthn/webauthn" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) -func TestHardDeleteUserPurgesAuthenticationDataWhenRedisFails(t *testing.T) { +func TestHardDeleteUserFailsClosedWhenAuthFenceCannotPublish(t *testing.T) { truncateTables(t) - user := User{Username: "hard-delete-user", Password: "password"} + user := User{Username: "hard-delete-user", Password: "password", TelegramId: "hard-delete-telegram"} require.NoError(t, DB.Create(&user).Error) + require.NoError(t, DB.Transaction(func(tx *gorm.DB) error { + return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id) + })) require.NoError(t, DB.Create(&Token{UserId: user.Id, Key: "hard-delete-token"}).Error) require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}).Error) require.NoError(t, DB.Create(&TwoFABackupCode{UserId: user.Id, CodeHash: "hash"}).Error) require.NoError(t, DB.Create(&PasskeyCredential{UserID: user.Id, CredentialID: "credential", PublicKey: "public-key"}).Error) require.NoError(t, DB.Create(&UserOAuthBinding{UserId: user.Id, ProviderId: 1, ProviderUserId: "provider-user"}).Error) + require.NoError(t, DB.Create(&UserSession{ + SID: "hard-delete-session", UserID: user.Id, Version: 1, UserAuthVersion: 1, + Status: UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password", + LastActiveAt: 1, ExpiresAt: 2, + }).Error) + require.NoError(t, DB.Create(&AuthFlow{ + TokenHash: "hard-delete-auth-flow", Purpose: AuthFlowPurposeTwoFALogin, + UserId: user.Id, ExpiresAt: time.Now().Add(time.Minute), + }).Error) oldRedisEnabled, oldRDB := common.RedisEnabled, common.RDB common.RedisEnabled = true - var cacheInvalidatedAfterCommit atomic.Bool common.RDB = redis.NewClient(&redis.Options{ Dialer: func(context.Context, string, string) (net.Conn, error) { - var count int64 - if err := DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error; err == nil && count == 0 { - cacheInvalidatedAfterCommit.Store(true) - } return nil, errors.New("forced redis failure") }, MaxRetries: -1, @@ -43,8 +53,58 @@ func TestHardDeleteUserPurgesAuthenticationDataWhenRedisFails(t *testing.T) { common.RedisEnabled, common.RDB = oldRedisEnabled, oldRDB }) + require.Error(t, HardDeleteUserById(user.Id)) + + var count int64 + require.NoError(t, DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error) + assert.EqualValues(t, 1, count) + for _, record := range []any{ + &Token{}, + &TwoFA{}, + &TwoFABackupCode{}, + &PasskeyCredential{}, + &UserOAuthBinding{}, + &UserSession{}, + &AuthFlow{}, + &ExternalIdentityClaim{}, + } { + require.NoError(t, DB.Unscoped().Model(record).Where("user_id = ?", user.Id).Count(&count).Error) + assert.EqualValues(t, 1, count) + } +} + +func TestHardDeleteUserPublishesTombstoneAndPurgesAuthenticationData(t *testing.T) { + truncateTables(t) + server := useUserCacheMiniRedis(t) + + user := User{ + Username: "hard-delete-success", Password: "password", AuthVersion: 1, + TelegramId: "hard-delete-success-telegram", + } + require.NoError(t, DB.Create(&user).Error) + require.NoError(t, DB.Transaction(func(tx *gorm.DB) error { + return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id) + })) + require.NoError(t, DB.Create(&Token{UserId: user.Id, Key: "hard-delete-success-token"}).Error) + require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}).Error) + require.NoError(t, DB.Create(&TwoFABackupCode{UserId: user.Id, CodeHash: "hash"}).Error) + require.NoError(t, DB.Create(&PasskeyCredential{UserID: user.Id, CredentialID: "credential-success", PublicKey: "public-key"}).Error) + require.NoError(t, DB.Create(&UserOAuthBinding{UserId: user.Id, ProviderId: 1, ProviderUserId: "provider-user-success"}).Error) + require.NoError(t, DB.Create(&UserSession{ + SID: "hard-delete-success-session", UserID: user.Id, Version: 1, UserAuthVersion: 1, + Status: UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password", + LastActiveAt: 1, ExpiresAt: 2, + }).Error) + require.NoError(t, DB.Create(&AuthFlow{ + TokenHash: "hard-delete-success-flow", Purpose: AuthFlowPurposeTwoFALogin, + UserId: user.Id, ExpiresAt: time.Now().Add(time.Minute), + }).Error) + require.NoError(t, populateUserCache(user)) + // Administrative hard deletion commonly targets an already soft-deleted + // user; the shared version increment must therefore query unscoped. + require.NoError(t, DB.Delete(&user).Error) + require.NoError(t, HardDeleteUserById(user.Id)) - assert.True(t, cacheInvalidatedAfterCommit.Load()) var count int64 require.NoError(t, DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error) @@ -55,10 +115,18 @@ func TestHardDeleteUserPurgesAuthenticationDataWhenRedisFails(t *testing.T) { &TwoFABackupCode{}, &PasskeyCredential{}, &UserOAuthBinding{}, + &UserSession{}, + &AuthFlow{}, + &ExternalIdentityClaim{}, } { require.NoError(t, DB.Unscoped().Model(record).Where("user_id = ?", user.Id).Count(&count).Error) assert.Zero(t, count) } + assert.False(t, server.Exists(getUserAuthFenceKey(user.Id))) + committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result() + require.NoError(t, err) + assert.Equal(t, "2", committed) + assert.False(t, server.Exists(getUserCacheKey(user.Id))) } func TestIncrementFailedAttemptsCountsConcurrentFailures(t *testing.T) { @@ -94,7 +162,10 @@ func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) { truncateTables(t) const code = "ABCD-1234" - require.NoError(t, CreateBackupCodes(123, []string{code})) + user := User{Id: 123, Username: "backup-code-user", Password: "password", AuthVersion: 1} + require.NoError(t, DB.Create(&user).Error) + require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: false}).Error) + require.NoError(t, CreatePendingTwoFASetupBackupCodes(user.Id, []string{code})) const attempts = 2 results := make(chan bool, attempts) @@ -128,3 +199,117 @@ func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) { require.NoError(t, err) assert.Zero(t, remaining) } + +func TestPendingTwoFASetupAPIsRejectEnabledFactor(t *testing.T) { + truncateTables(t) + + user := User{Username: "enabled-twofa-guard", Password: "password", AuthVersion: 1} + require.NoError(t, DB.Create(&user).Error) + twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true} + require.NoError(t, DB.Create(&twoFA).Error) + + require.Error(t, CreatePendingTwoFASetupBackupCodes(user.Id, []string{"ABCD-1234"})) + require.Error(t, twoFA.DeletePendingTwoFASetup()) + + var stored TwoFA + require.NoError(t, DB.First(&stored, twoFA.Id).Error) + assert.True(t, stored.IsEnabled) + var backupCodeCount int64 + require.NoError(t, DB.Model(&TwoFABackupCode{}).Where("user_id = ?", user.Id).Count(&backupCodeCount).Error) + assert.Zero(t, backupCodeCount) +} + +func TestSecurityFactorMutationsAdvanceUserAuthVersion(t *testing.T) { + truncateTables(t) + + user := User{ + Username: "security-factor-version-user", + Password: "password", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + AuthVersion: 1, + } + require.NoError(t, DB.Create(&user).Error) + twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: false} + require.NoError(t, DB.Create(&twoFA).Error) + + require.NoError(t, twoFA.EnableWithAuthVersion()) + assertUserAuthVersion(t, user.Id, 2) + assert.ErrorIs(t, twoFA.EnableWithAuthVersion(), ErrTwoFAAlreadyEnabled) + assertUserAuthVersion(t, user.Id, 2) + require.NoError(t, ReplaceBackupCodesWithAuthVersion(user.Id, []string{"ABCD-1234"})) + assertUserAuthVersion(t, user.Id, 3) + require.NoError(t, DisableTwoFAWithAuthVersion(user.Id)) + assertUserAuthVersion(t, user.Id, 4) + + credential := &PasskeyCredential{UserID: user.Id, CredentialID: "credential-id", PublicKey: "public-key"} + require.NoError(t, UpsertPasskeyCredentialWithAuthVersion(credential)) + assertUserAuthVersion(t, user.Id, 5) + require.NoError(t, DeletePasskeyByUserIDWithAuthVersion(user.Id)) + assertUserAuthVersion(t, user.Id, 6) +} + +func TestUpdatePasskeyAssertionStateCannotRewriteRegistrationIdentity(t *testing.T) { + truncateTables(t) + + user := User{Username: "passkey-assertion-state", Password: "password", AuthVersion: 1} + require.NoError(t, DB.Create(&user).Error) + credentialID := []byte("stable-credential-id") + stored := PasskeyCredential{ + UserID: user.Id, + CredentialID: base64.StdEncoding.EncodeToString(credentialID), + PublicKey: "original-public-key", + AttestationType: "packed", + AAGUID: "original-aaguid", + SignCount: 1, + Transports: `["usb"]`, + Attachment: "platform", + } + require.NoError(t, DB.Create(&stored).Error) + usedAt := time.Now().UTC().Truncate(time.Second) + validated := &webauthn.Credential{ + ID: credentialID, + PublicKey: []byte("replacement-public-key"), + AttestationType: "none", + Flags: webauthn.CredentialFlags{ + UserPresent: true, + UserVerified: true, + BackupEligible: true, + BackupState: true, + }, + Authenticator: webauthn.Authenticator{ + AAGUID: []byte("replacement-aaguid"), + SignCount: 8, + CloneWarning: true, + }, + } + require.NoError(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt)) + + var updated PasskeyCredential + require.NoError(t, DB.First(&updated, stored.ID).Error) + assert.Equal(t, stored.CredentialID, updated.CredentialID) + assert.Equal(t, stored.PublicKey, updated.PublicKey) + assert.Equal(t, stored.AttestationType, updated.AttestationType) + assert.Equal(t, stored.AAGUID, updated.AAGUID) + assert.Equal(t, stored.Transports, updated.Transports) + assert.Equal(t, stored.Attachment, updated.Attachment) + assert.EqualValues(t, 8, updated.SignCount) + assert.True(t, updated.CloneWarning) + assert.True(t, updated.UserPresent) + assert.True(t, updated.UserVerified) + assert.True(t, updated.BackupEligible) + assert.True(t, updated.BackupState) + require.NotNil(t, updated.LastUsedAt) + assert.Equal(t, usedAt.Unix(), updated.LastUsedAt.Unix()) + + validated.ID = []byte("another-credential") + assert.ErrorIs(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt), ErrPasskeyNotFound) +} + +func assertUserAuthVersion(t *testing.T, userID int, expected int64) { + t.Helper() + var version int64 + require.NoError(t, DB.Model(&User{}).Where("id = ?", userID).Select("auth_version").Scan(&version).Error) + assert.Equal(t, expected, version) +} diff --git a/model/user_cache.go b/model/user_cache.go index 2a246c84d09e..2ca84f402221 100644 --- a/model/user_cache.go +++ b/model/user_cache.go @@ -1,27 +1,29 @@ package model import ( + "errors" "fmt" - "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/gin-gonic/gin" - - "github.com/bytedance/gopkg/util/gopool" ) -// UserBase struct remains the same as it represents the cached data structure +const userCacheSchemaVersion = 2 + type UserBase struct { - Id int `json:"id"` - Group string `json:"group"` - Email string `json:"email"` - Quota int `json:"quota"` - Status int `json:"status"` - Username string `json:"username"` - Setting string `json:"setting"` + Id int `json:"id"` + Group string `json:"group"` + Email string `json:"email"` + Quota int `json:"quota"` + Status int `json:"status"` + Role int `json:"role"` + Username string `json:"username"` + Setting string `json:"setting"` + AuthVersion int64 `json:"-"` + CacheSchema int `json:"-"` } func (user *UserBase) WriteContext(c *gin.Context) { @@ -49,6 +51,14 @@ func getUserCacheKey(userId int) string { return fmt.Sprintf("user:%d", userId) } +func userCacheTTLSeconds() int { + ttl := common.RedisKeyCacheSeconds() + if ttl <= 0 { + return 60 + } + return ttl +} + // invalidateUserCache clears user cache func invalidateUserCache(userId int) error { if !common.RedisEnabled { @@ -67,12 +77,7 @@ func populateUserCache(user User) error { if !common.RedisEnabled { return nil } - - return common.RedisHSetObj( - getUserCacheKey(user.Id), - user.ToBaseUser(), - time.Duration(common.RedisKeyCacheSeconds())*time.Second, - ) + return writeUserCache(user.ToBaseUser(), true) } // updateUserCache refreshes non-quota user cache fields. @@ -82,61 +87,37 @@ func updateUserCache(user User) error { if !common.RedisEnabled { return nil } - if err := updateUserGroupCache(user.Id, user.Group); err != nil { - return err - } - if err := updateUserEmailCache(user.Id, user.Email); err != nil { - return err - } - if err := updateUserStatusCache(user.Id, user.Status == common.UserStatusEnabled); err != nil { - return err - } - if err := updateUserNameCache(user.Id, user.Username); err != nil { - return err - } - return updateUserSettingCache(user.Id, user.Setting) + return writeUserCache(user.ToBaseUser(), false) } // GetUserCache gets complete user cache from hash -func GetUserCache(userId int) (userCache *UserBase, err error) { - var user *User - var fromDB bool - defer func() { - // Update Redis cache asynchronously on successful DB read - if shouldUpdateRedis(fromDB, err) && user != nil { - gopool.Go(func() { - if err := populateUserCache(*user); err != nil { - common.SysLog("failed to update user status cache: " + err.Error()) - } - }) - } - }() - +func GetUserCache(userId int) (*UserBase, error) { // Try getting from Redis first - userCache, err = cacheGetUserBase(userId) + userCache, err := cacheGetUserBase(userId) if err == nil { return userCache, nil } - // If Redis fails, get from DB - fromDB = true - user, err = GetUserById(userId, false) + // Redis misses and read failures both fall back to the shared database. A + // version fence newer than the database is the one exception: allowing that + // snapshot would re-authorize a user while a restrictive update is pending. + user, err := GetUserById(userId, false) if err != nil { - return nil, err // Return nil and error if DB lookup fails + return nil, err } - - // Create cache object from user data - userCache = &UserBase{ - Id: user.Id, - Group: user.Group, - Quota: user.Quota, - Status: user.Status, - Username: user.Username, - Setting: user.Setting, - Email: user.Email, + if common.RedisEnabled { + floor, floorErr := getUserAuthVersionFloor(userId) + if floorErr == nil && floor > user.AuthVersion { + return nil, ErrUserAuthCachePending + } + if err := populateUserCache(*user); err != nil { + if errors.Is(err, ErrUserAuthCachePending) { + return nil, err + } + common.SysLog("failed to synchronously populate user cache: " + err.Error()) + } } - - return userCache, nil + return user.ToBaseUser(), nil } func cacheGetUserBase(userId int) (*UserBase, error) { @@ -149,6 +130,16 @@ func cacheGetUserBase(userId int) (*UserBase, error) { if err != nil { return nil, err } + if userCache.Id != userId || userCache.CacheSchema != userCacheSchemaVersion || userCache.AuthVersion <= 0 { + return nil, fmt.Errorf("user cache schema is stale") + } + floor, err := getUserAuthVersionFloor(userId) + if err != nil { + return nil, err + } + if floor > userCache.AuthVersion { + return nil, ErrUserAuthCachePending + } return &userCache, nil } @@ -207,14 +198,11 @@ func getUserSettingCache(userId int) (dto.UserSetting, error) { // New functions for individual field updates func updateUserStatusCache(userId int, status bool) error { - if !common.RedisEnabled { - return nil - } statusInt := common.UserStatusEnabled if !status { statusInt = common.UserStatusDisabled } - return common.RedisHSetField(getUserCacheKey(userId), "Status", fmt.Sprintf("%d", statusInt)) + return updateUserCacheField(userId, "Status", statusInt) } func updateUserQuotaCache(userId int, quota int) error { @@ -224,36 +212,74 @@ func updateUserQuotaCache(userId int, quota int) error { return common.RedisHSetField(getUserCacheKey(userId), "Quota", fmt.Sprintf("%d", quota)) } -func updateUserGroupCache(userId int, group string) error { +// RefreshUserGroupCache writes the database-authoritative group into an +// existing user hash without changing the user's authentication version. +func RefreshUserGroupCache(userId int) error { if !common.RedisEnabled { return nil } - return common.RedisHSetField(getUserCacheKey(userId), "Group", group) -} + if userId <= 0 { + return fmt.Errorf("invalid user id") + } + var authoritative User + if err := DB.Select("id", "auth_version", commonGroupCol).Where("id = ?", userId).First(&authoritative).Error; err != nil { + return err + } + // Group transitions intentionally keep the same authentication version. A + // refresh that read the previous group can therefore arrive after a newer + // refresh and still pass the auth-version fence. Re-read after every write + // and repair the cache when the authoritative group changed in between. + for range 3 { + if err := updateUserCacheFieldAtVersion(userId, "Group", authoritative.Group, authoritative.AuthVersion); err != nil { + return err + } + + var verified User + if err := DB.Select("id", "auth_version", commonGroupCol).Where("id = ?", userId).First(&verified).Error; err != nil { + return err + } + if verified.AuthVersion == authoritative.AuthVersion && verified.Group == authoritative.Group { + return nil + } + authoritative = verified + } -func UpdateUserGroupCache(userId int, group string) error { - return updateUserGroupCache(userId, group) + // Preserve the freshest snapshot observed even when the row was too busy to + // stabilize within the bounded retries. Returning an error lets best-effort + // callers emit an operation-specific warning. + if err := updateUserCacheFieldAtVersion(userId, "Group", authoritative.Group, authoritative.AuthVersion); err != nil { + return err + } + return fmt.Errorf("user group changed repeatedly during cache refresh") } func updateUserEmailCache(userId int, email string) error { - if !common.RedisEnabled { - return nil - } - return common.RedisHSetField(getUserCacheKey(userId), "Email", email) + return updateUserCacheField(userId, "Email", email) } func updateUserNameCache(userId int, username string) error { - if !common.RedisEnabled { - return nil - } - return common.RedisHSetField(getUserCacheKey(userId), "Username", username) + return updateUserCacheField(userId, "Username", username) } func updateUserSettingCache(userId int, setting string) error { + return updateUserCacheField(userId, "Setting", setting) +} + +// updateUserCacheField prevents individual cache refreshes from bypassing the +// auth-version fence. It intentionally does nothing when the complete hash is +// absent; the next GetUserCache call will repopulate it from the database. +func updateUserCacheField(userId int, field string, value interface{}) error { if !common.RedisEnabled { return nil } - return common.RedisHSetField(getUserCacheKey(userId), "Setting", setting) + var user User + if err := DB.Select("id", "auth_version").Where("id = ?", userId).First(&user).Error; err != nil { + return err + } + if user.AuthVersion <= 0 { + return fmt.Errorf("invalid user auth version") + } + return updateUserCacheFieldAtVersion(userId, field, value, user.AuthVersion) } // GetUserLanguage returns the user's language preference from cache diff --git a/model/user_cache_auth_version_test.go b/model/user_cache_auth_version_test.go new file mode 100644 index 000000000000..769db103ca75 --- /dev/null +++ b/model/user_cache_auth_version_test.go @@ -0,0 +1,223 @@ +package model + +import ( + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/alicebob/miniredis/v2" + "github.com/go-redis/redis/v8" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func useUserCacheMiniRedis(t *testing.T) *miniredis.Miniredis { + t.Helper() + server := miniredis.RunT(t) + oldRedisEnabled := common.RedisEnabled + oldRDB := common.RDB + oldSyncFrequency := common.SyncFrequency + common.RedisEnabled = true + common.SyncFrequency = 2 + common.RDB = redis.NewClient(&redis.Options{Addr: server.Addr()}) + t.Cleanup(func() { + _ = common.RDB.Close() + common.RedisEnabled = oldRedisEnabled + common.RDB = oldRDB + common.SyncFrequency = oldSyncFrequency + }) + return server +} + +func TestUserAuthFenceRollbackExpiresAndRecovers(t *testing.T) { + truncateTables(t) + server := useUserCacheMiniRedis(t) + + user := User{ + Username: "auth-fence-rollback", + Password: "password", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + AuthVersion: 1, + } + require.NoError(t, DB.Create(&user).Error) + require.NoError(t, populateUserCache(user)) + + tx := DB.Begin() + require.NoError(t, tx.Error) + next, err := IncrementUserAuthVersionWithTx(tx, user.Id) + require.NoError(t, err) + assert.EqualValues(t, 2, next) + + _, err = cacheGetUserBase(user.Id) + assert.ErrorIs(t, err, ErrUserAuthCachePending) + cacheTTL, err := common.RDB.TTL(t.Context(), getUserCacheKey(user.Id)).Result() + require.NoError(t, err) + fenceTTL, err := common.RDB.TTL(t.Context(), getUserAuthFenceKey(user.Id)).Result() + require.NoError(t, err) + assert.Greater(t, fenceTTL, cacheTTL) + require.NoError(t, tx.Rollback().Error) + + server.FastForward(time.Duration(userAuthFenceTTLSeconds()+1) * time.Second) + assert.False(t, server.Exists(getUserAuthFenceKey(user.Id))) + committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result() + require.NoError(t, err) + assert.Equal(t, "1", committed) + + cached, err := GetUserCache(user.Id) + require.NoError(t, err) + assert.EqualValues(t, 1, cached.AuthVersion) +} + +func TestPendingUserAuthFenceRejectsStaleCacheWrite(t *testing.T) { + server := useUserCacheMiniRedis(t) + const userID = 4201 + require.NoError(t, SetUserAuthVersionFence(userID, 2)) + + err := writeUserCache(&UserBase{ + Id: userID, Group: "default", Username: "stale", AuthVersion: 1, + }, true) + + assert.ErrorIs(t, err, ErrUserAuthCachePending) + assert.False(t, server.Exists(getUserCacheKey(userID))) +} + +func TestUserAuthFieldUpdateRejectsVersionMismatch(t *testing.T) { + useUserCacheMiniRedis(t) + const userID = 4202 + require.NoError(t, writeUserCache(&UserBase{ + Id: userID, Group: "current", Username: "cached", AuthVersion: 3, + }, true)) + + err := updateUserCacheFieldAtVersion(userID, "Group", "stale", 2) + + assert.ErrorIs(t, err, ErrUserAuthCachePending) + group, err := common.RDB.HGet(t.Context(), getUserCacheKey(userID), "Group").Result() + require.NoError(t, err) + assert.Equal(t, "current", group) +} + +func TestRefreshUserGroupCacheRepairsDelayedSameVersionWrite(t *testing.T) { + truncateTables(t) + useUserCacheMiniRedis(t) + + user := User{ + Username: "delayed-group-refresh", + Password: "password", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + AuthVersion: 1, + } + require.NoError(t, DB.Create(&user).Error) + require.NoError(t, populateUserCache(user)) + + firstSnapshotRead := make(chan struct{}) + releaseDelayedRefresh := make(chan struct{}) + var intercepted atomic.Bool + const callbackName = "test:block_delayed_group_refresh" + require.NoError(t, DB.Callback().Query().After("gorm:query").Register(callbackName, func(*gorm.DB) { + if intercepted.CompareAndSwap(false, true) { + close(firstSnapshotRead) + <-releaseDelayedRefresh + } + })) + t.Cleanup(func() { + _ = DB.Callback().Query().Remove(callbackName) + }) + + delayedResult := make(chan error, 1) + go func() { + delayedResult <- RefreshUserGroupCache(user.Id) + }() + <-firstSnapshotRead + + require.NoError(t, DB.Model(&User{}).Where("id = ?", user.Id).Update("group", "pro").Error) + require.NoError(t, RefreshUserGroupCache(user.Id)) + cached, err := cacheGetUserBase(user.Id) + require.NoError(t, err) + assert.Equal(t, "pro", cached.Group) + assert.EqualValues(t, 1, cached.AuthVersion) + + close(releaseDelayedRefresh) + require.NoError(t, <-delayedResult) + cached, err = cacheGetUserBase(user.Id) + require.NoError(t, err) + assert.Equal(t, "pro", cached.Group) + assert.EqualValues(t, 1, cached.AuthVersion) +} + +func TestCommittedUserAuthVersionPermanentlyRejectsDelayedCacheFill(t *testing.T) { + truncateTables(t) + server := useUserCacheMiniRedis(t) + + user := User{ + Username: "auth-fence-commit", + Password: "password", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + AuthVersion: 1, + } + require.NoError(t, DB.Create(&user).Error) + require.NoError(t, populateUserCache(user)) + stale := *user.ToBaseUser() + + require.NoError(t, DB.Transaction(func(tx *gorm.DB) error { + _, err := IncrementUserAuthVersionWithTx(tx, user.Id) + return err + })) + require.NoError(t, PublishUserAuthCache(user.Id)) + assert.False(t, server.Exists(getUserAuthFenceKey(user.Id))) + committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result() + require.NoError(t, err) + assert.Equal(t, "2", committed) + + server.FastForward(time.Duration(userAuthFenceTTLSeconds()+1) * time.Second) + require.NoError(t, common.RedisDelKey(getUserCacheKey(user.Id))) + err = writeUserCache(&stale, true) + assert.True(t, errors.Is(err, ErrUserAuthCachePending)) + committed, err = common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result() + require.NoError(t, err) + assert.Equal(t, "2", committed) +} + +func TestUserAuthVersionFenceAndCommittedFloorAreMonotonic(t *testing.T) { + truncateTables(t) + server := useUserCacheMiniRedis(t) + + const userID = 4101 + require.NoError(t, SetUserAuthVersionFence(userID, 5)) + require.NoError(t, SetUserAuthVersionFence(userID, 3)) + pending, err := common.RDB.Get(t.Context(), getUserAuthFenceKey(userID)).Result() + require.NoError(t, err) + assert.Equal(t, "5", pending) + floor, err := getUserAuthVersionFloor(userID) + require.NoError(t, err) + assert.EqualValues(t, 5, floor) + + // Committing an older transaction must neither clear a newer pending fence + // nor lower the effective deny floor. + require.NoError(t, publishCommittedUserAuthVersion(userID, 3)) + pending, err = common.RDB.Get(t.Context(), getUserAuthFenceKey(userID)).Result() + require.NoError(t, err) + assert.Equal(t, "5", pending) + floor, err = getUserAuthVersionFloor(userID) + require.NoError(t, err) + assert.EqualValues(t, 5, floor) + + require.NoError(t, publishCommittedUserAuthVersion(userID, 5)) + assert.False(t, server.Exists(getUserAuthFenceKey(userID))) + committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(userID)).Result() + require.NoError(t, err) + assert.Equal(t, "5", committed) + + require.NoError(t, publishCommittedUserAuthVersion(userID, 4)) + committed, err = common.RDB.Get(t.Context(), getUserAuthVersionKey(userID)).Result() + require.NoError(t, err) + assert.Equal(t, "5", committed) +} diff --git a/model/user_session.go b/model/user_session.go new file mode 100644 index 000000000000..2850ea2c2dda --- /dev/null +++ b/model/user_session.go @@ -0,0 +1,593 @@ +package model + +import ( + "context" + "crypto/hmac" + "errors" + "fmt" + "time" + + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" +) + +const ( + UserSessionStatusActive = "active" + UserSessionStatusRevoking = "revoking" + UserSessionStatusRevoked = "revoked" + + userSessionCacheSchema = 1 +) + +var ( + ErrUserSessionInvalid = errors.New("user session is invalid") + ErrUserSessionInactive = errors.New("user session is inactive") + ErrUserSessionRefreshInvalid = errors.New("user session refresh token is invalid") + ErrUserSessionRefreshRace = errors.New("user session refresh is already in progress") + ErrUserSessionRefreshReuse = errors.New("user session refresh token was reused") +) + +// UserSession is the server-side control plane for short-lived access JWTs. +// RefreshHash values are HMAC digests supplied by the service layer; opaque +// refresh secrets are never persisted. +type UserSession struct { + SID string `json:"sid" gorm:"column:sid;type:varchar(64);primaryKey"` + UserID int `json:"user_id" gorm:"column:user_id;not null;index:idx_user_sessions_user_status_expiry,priority:1"` + Version int64 `json:"version" gorm:"type:bigint;not null;default:1"` + UserAuthVersion int64 `json:"user_auth_version" gorm:"type:bigint;not null"` + Status string `json:"status" gorm:"type:varchar(16);not null;index:idx_user_sessions_user_status_expiry,priority:2"` + RefreshHash string `json:"-" gorm:"type:char(64);not null"` + PreviousRefreshHash string `json:"-" gorm:"type:char(64)"` + PreviousValidUntil int64 `json:"-" gorm:"type:bigint;not null;default:0"` + LoginMethod string `json:"login_method" gorm:"type:varchar(32);not null"` + IP string `json:"ip" gorm:"type:varchar(64)"` + UserAgent string `json:"user_agent" gorm:"type:text"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` + LastActiveAt int64 `json:"last_active_at" gorm:"type:bigint;not null;column:last_active_at"` + ExpiresAt int64 `json:"expires_at" gorm:"type:bigint;not null;column:expires_at;index:idx_user_sessions_user_status_expiry,priority:3"` + RevokedAt int64 `json:"revoked_at,omitempty" gorm:"type:bigint;not null;default:0;column:revoked_at"` + RevokedReason string `json:"revoked_reason,omitempty" gorm:"type:varchar(64);column:revoked_reason"` +} + +func (UserSession) TableName() string { + return "user_sessions" +} + +type userSessionCacheEntry struct { + SID string + UserID int + Version int64 + UserAuthVersion int64 + Status string + LoginMethod string + IP string + UserAgent string + CreatedAt int64 + LastActiveAt int64 + ExpiresAt int64 + RevokedAt int64 + RevokedReason string + CacheSchema int +} + +func (session *UserSession) cacheEntry() *userSessionCacheEntry { + return &userSessionCacheEntry{ + SID: session.SID, + UserID: session.UserID, + Version: session.Version, + UserAuthVersion: session.UserAuthVersion, + Status: session.Status, + LoginMethod: session.LoginMethod, + IP: session.IP, + UserAgent: session.UserAgent, + CreatedAt: session.CreatedAt, + LastActiveAt: session.LastActiveAt, + ExpiresAt: session.ExpiresAt, + RevokedAt: session.RevokedAt, + RevokedReason: session.RevokedReason, + CacheSchema: userSessionCacheSchema, + } +} + +func (entry *userSessionCacheEntry) session() *UserSession { + return &UserSession{ + SID: entry.SID, + UserID: entry.UserID, + Version: entry.Version, + UserAuthVersion: entry.UserAuthVersion, + Status: entry.Status, + LoginMethod: entry.LoginMethod, + IP: entry.IP, + UserAgent: entry.UserAgent, + CreatedAt: entry.CreatedAt, + LastActiveAt: entry.LastActiveAt, + ExpiresAt: entry.ExpiresAt, + RevokedAt: entry.RevokedAt, + RevokedReason: entry.RevokedReason, + } +} + +func userSessionCacheKey(sid string) string { + digest := common.GenerateHMACWithKey([]byte("user-session-cache-v1:"+common.SessionSecret), sid) + return "auth:session:" + digest +} + +func CreateUserSession(session *UserSession) error { + if session == nil || session.SID == "" || session.UserID <= 0 || session.UserAuthVersion <= 0 || session.RefreshHash == "" || session.ExpiresAt <= time.Now().Unix() { + return ErrUserSessionInvalid + } + if session.Version <= 0 { + session.Version = 1 + } + if session.Status == "" { + session.Status = UserSessionStatusActive + } + if session.Status != UserSessionStatusActive || session.RevokedAt != 0 { + return ErrUserSessionInvalid + } + if session.LastActiveAt == 0 { + session.LastActiveAt = time.Now().Unix() + } + if err := DB.Create(session).Error; err != nil { + return err + } + if err := writeUserSessionCache(session.cacheEntry()); err != nil { + common.SysLog("failed to populate newly created user session cache: " + err.Error()) + } + return nil +} + +func GetUserSessionBySID(sid string) (*UserSession, error) { + if sid == "" { + return nil, ErrUserSessionInvalid + } + var session UserSession + if err := DB.Where("sid = ?", sid).First(&session).Error; err != nil { + return nil, err + } + return &session, nil +} + +// GetUserSessionCached validates cached state first and falls back to the +// database on a miss or Redis read failure. A deny tombstone never falls back. +func GetUserSessionCached(sid string) (*UserSession, error) { + if sid == "" { + return nil, ErrUserSessionInvalid + } + if common.RedisEnabled { + entry, err := getUserSessionCache(sid) + if err == nil { + return entry.session(), nil + } + if errors.Is(err, ErrUserSessionInactive) { + return nil, err + } + } + + session, err := GetUserSessionBySID(sid) + if err != nil { + return nil, err + } + now := time.Now().Unix() + if session.Status != UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now { + if common.RedisEnabled { + entry := session.cacheEntry() + entry.Status = UserSessionStatusRevoked + _ = writeUserSessionCache(entry) + } + return nil, ErrUserSessionInactive + } + if common.RedisEnabled { + if err := writeUserSessionCache(session.cacheEntry()); err != nil { + if errors.Is(err, ErrUserSessionInactive) { + return nil, err + } + common.SysLog("failed to synchronously populate user session cache: " + err.Error()) + } + } + return session, nil +} + +func getUserSessionCache(sid string) (*userSessionCacheEntry, error) { + var entry userSessionCacheEntry + if err := common.RedisHGetObj(userSessionCacheKey(sid), &entry); err != nil { + return nil, err + } + if entry.CacheSchema != userSessionCacheSchema || entry.SID != sid || entry.UserID <= 0 || entry.Version <= 0 || entry.UserAuthVersion <= 0 { + return nil, fmt.Errorf("user session cache schema is stale") + } + if entry.Status != UserSessionStatusActive || entry.RevokedAt != 0 || entry.ExpiresAt <= time.Now().Unix() { + return nil, ErrUserSessionInactive + } + return &entry, nil +} + +func writeUserSessionCache(entry *userSessionCacheEntry) error { + if entry == nil || !common.RedisEnabled { + return nil + } + ttl := entry.ExpiresAt - time.Now().Unix() + if ttl <= 0 { + ttl = 1 + } + entry.CacheSchema = userSessionCacheSchema + const script = ` +local current_status = redis.call('HGET', KEYS[1], 'Status') +local current_version = tonumber(redis.call('HGET', KEYS[1], 'Version') or '0') +if ARGV[5] == 'active' and (current_status == 'revoking' or current_status == 'revoked') then + return 0 +end +if current_version > tonumber(ARGV[3]) then + return 0 +end +redis.call('HSET', KEYS[1], + 'SID', ARGV[1], 'UserID', ARGV[2], 'Version', ARGV[3], + 'UserAuthVersion', ARGV[4], 'Status', ARGV[5], + 'LoginMethod', ARGV[6], 'IP', ARGV[7], 'UserAgent', ARGV[8], + 'CreatedAt', ARGV[9], 'LastActiveAt', ARGV[10], 'ExpiresAt', ARGV[11], + 'RevokedAt', ARGV[12], 'RevokedReason', ARGV[13], 'CacheSchema', ARGV[14]) +redis.call('EXPIRE', KEYS[1], ARGV[15]) +return 1` + result, err := common.RDB.Eval(context.Background(), script, []string{userSessionCacheKey(entry.SID)}, + entry.SID, entry.UserID, entry.Version, entry.UserAuthVersion, entry.Status, + entry.LoginMethod, entry.IP, entry.UserAgent, entry.CreatedAt, entry.LastActiveAt, + entry.ExpiresAt, entry.RevokedAt, entry.RevokedReason, entry.CacheSchema, ttl, + ).Int() + if err != nil { + return err + } + if result == 0 { + return ErrUserSessionInactive + } + return nil +} + +func writeUserSessionDenyFence(session *UserSession, status string, now int64, reason string) error { + if !common.RedisEnabled { + return nil + } + entry := session.cacheEntry() + entry.Status = status + entry.RevokedAt = now + entry.RevokedReason = reason + return writeUserSessionCache(entry) +} + +func ListActiveUserSessions(userID int, now int64) ([]UserSession, error) { + if userID <= 0 { + return nil, ErrUserSessionInvalid + } + if now <= 0 { + now = time.Now().Unix() + } + var authVersion int64 + if err := DB.Model(&User{}).Where("id = ?", userID).Select("auth_version").Find(&authVersion).Error; err != nil { + return nil, err + } + if authVersion <= 0 { + return nil, ErrUserSessionInvalid + } + var sessions []UserSession + err := DB.Where("user_id = ? AND user_auth_version = ? AND status = ? AND expires_at > ?", userID, authVersion, UserSessionStatusActive, now). + Order("last_active_at DESC").Order("created_at DESC").Find(&sessions).Error + return sessions, err +} + +// RotateUserSessionRefresh atomically rotates HMAC digests. The UPDATE itself +// is a compare-and-swap so SQLite, where lockForUpdate is intentionally a +// no-op, has the same single-winner behavior as MySQL and PostgreSQL. Only a +// recognized previous digest outside its grace window is treated as reuse; +// an unknown secret never revokes the victim session. +func RotateUserSessionRefresh(userID int, sid, presentedHash, nextHash string, now int64, grace time.Duration) (*UserSession, error) { + if userID <= 0 || sid == "" || presentedHash == "" || nextHash == "" || hmac.Equal([]byte(presentedHash), []byte(nextHash)) { + return nil, ErrUserSessionInvalid + } + if now <= 0 { + now = time.Now().Unix() + } + graceSeconds := int64(grace / time.Second) + if graceSeconds < 0 { + return nil, ErrUserSessionInvalid + } + for range 3 { + var session UserSession + if err := DB.Where("sid = ? AND user_id = ?", sid, userID).First(&session).Error; err != nil { + return nil, err + } + if session.Status != UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now { + return nil, ErrUserSessionInactive + } + + if hmac.Equal([]byte(session.RefreshHash), []byte(presentedHash)) { + result := DB.Model(&UserSession{}). + Where("sid = ? AND user_id = ? AND status = ? AND revoked_at = ? AND expires_at > ? AND refresh_hash = ?", + sid, userID, UserSessionStatusActive, 0, now, presentedHash). + Updates(map[string]interface{}{ + "previous_refresh_hash": session.RefreshHash, + "previous_valid_until": now + graceSeconds, + "refresh_hash": nextHash, + "last_active_at": now, + }) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + continue + } + session.PreviousRefreshHash = session.RefreshHash + session.PreviousValidUntil = now + graceSeconds + session.RefreshHash = nextHash + session.LastActiveAt = now + if err := writeUserSessionCache(session.cacheEntry()); err != nil && !errors.Is(err, ErrUserSessionInactive) { + common.SysLog("failed to update rotated user session cache: " + err.Error()) + } + return &session, nil + } + + if session.PreviousRefreshHash == "" || !hmac.Equal([]byte(session.PreviousRefreshHash), []byte(presentedHash)) { + return nil, ErrUserSessionRefreshInvalid + } + if now <= session.PreviousValidUntil { + return &session, ErrUserSessionRefreshRace + } + + // Once a known previous token is replayed outside the grace window the + // whole token family is compromised. Publish the deny fence first, then + // revoke the active row regardless of a concurrent refresh rotation. + if err := writeUserSessionDenyFence(&session, UserSessionStatusRevoking, now, "refresh_reuse"); err != nil { + return nil, err + } + result := DB.Model(&UserSession{}). + Where("sid = ? AND user_id = ? AND status = ? AND revoked_at = ? AND expires_at > ?", + sid, userID, UserSessionStatusActive, 0, now). + Updates(map[string]interface{}{ + "status": UserSessionStatusRevoked, + "revoked_at": now, + "revoked_reason": "refresh_reuse", + }) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, ErrUserSessionInactive + } + session.Status = UserSessionStatusRevoked + session.RevokedAt = now + session.RevokedReason = "refresh_reuse" + if err := writeUserSessionCache(session.cacheEntry()); err != nil { + common.SysLog("failed to cache refresh-reuse session revoke: " + err.Error()) + } + return nil, ErrUserSessionRefreshReuse + } + return nil, ErrUserSessionRefreshInvalid +} + +func RevokeUserSession(userID int, sid, reason string) (bool, error) { + if userID <= 0 || sid == "" { + return false, ErrUserSessionInvalid + } + now := time.Now().Unix() + var candidate UserSession + if err := DB.Where("sid = ? AND user_id = ?", sid, userID).First(&candidate).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + if candidate.Status != UserSessionStatusActive || candidate.RevokedAt != 0 || candidate.ExpiresAt <= now { + return false, nil + } + if err := writeUserSessionDenyFence(&candidate, UserSessionStatusRevoking, now, reason); err != nil { + return false, err + } + + var revoked bool + err := DB.Transaction(func(tx *gorm.DB) error { + var current UserSession + if err := lockForUpdate(tx).Where("sid = ? AND user_id = ?", sid, userID).First(¤t).Error; err != nil { + return err + } + if current.Status != UserSessionStatusActive || current.RevokedAt != 0 || current.ExpiresAt <= now { + return nil + } + result := tx.Model(&UserSession{}).Where("sid = ? AND status = ?", sid, UserSessionStatusActive).Updates(map[string]interface{}{ + "status": UserSessionStatusRevoked, + "revoked_at": now, + "revoked_reason": reason, + }) + if result.Error != nil { + return result.Error + } + revoked = result.RowsAffected == 1 + return nil + }) + if err != nil { + return false, err + } + if revoked { + candidate.Status = UserSessionStatusRevoked + candidate.RevokedAt = now + candidate.RevokedReason = reason + if err := writeUserSessionCache(candidate.cacheEntry()); err != nil { + common.SysLog("failed to finalize user session revoke tombstone: " + err.Error()) + } + } + return revoked, nil +} + +// RevokeUserSessionByRefreshHash is used when logout is authenticated only by +// the HttpOnly refresh cookie. Possession of a SID alone is insufficient. The +// immediately previous digest is accepted only inside the refresh race window. +func RevokeUserSessionByRefreshHash(sid, presentedHash, reason string) (bool, error) { + if sid == "" || presentedHash == "" { + return false, ErrUserSessionInvalid + } + now := time.Now().Unix() + var session UserSession + var revoked bool + err := DB.Transaction(func(tx *gorm.DB) error { + if err := lockForUpdate(tx).Where("sid = ?", sid).First(&session).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if session.Status != UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now { + return nil + } + validCurrent := hmac.Equal([]byte(session.RefreshHash), []byte(presentedHash)) + validPrevious := session.PreviousRefreshHash != "" && now <= session.PreviousValidUntil && + hmac.Equal([]byte(session.PreviousRefreshHash), []byte(presentedHash)) + if !validCurrent && !validPrevious { + return nil + } + if err := writeUserSessionDenyFence(&session, UserSessionStatusRevoking, now, reason); err != nil { + return err + } + result := tx.Model(&UserSession{}).Where("sid = ? AND status = ?", sid, UserSessionStatusActive).Updates(map[string]interface{}{ + "status": UserSessionStatusRevoked, + "revoked_at": now, + "revoked_reason": reason, + }) + if result.Error != nil { + return result.Error + } + revoked = result.RowsAffected == 1 + if revoked { + session.Status = UserSessionStatusRevoked + session.RevokedAt = now + session.RevokedReason = reason + } + return nil + }) + if err != nil { + return false, err + } + if revoked { + if err := writeUserSessionCache(session.cacheEntry()); err != nil { + common.SysLog("failed to finalize refresh-authenticated session revoke tombstone: " + err.Error()) + } + } + return revoked, nil +} + +// AdvanceUserSessionAuthVersion preserves one browser session across a +// user-level security-version change. Both old access JWTs and concurrent +// updates are invalidated by advancing the per-session version as well. +func AdvanceUserSessionAuthVersion(userID int, sid string, expectedSessionVersion, expectedUserAuthVersion, nextUserAuthVersion int64) (*UserSession, error) { + if userID <= 0 || sid == "" || expectedSessionVersion <= 0 || expectedUserAuthVersion <= 0 || nextUserAuthVersion <= expectedUserAuthVersion { + return nil, ErrUserSessionInvalid + } + now := time.Now().Unix() + var session UserSession + err := DB.Transaction(func(tx *gorm.DB) error { + if err := lockForUpdate(tx).Where("sid = ? AND user_id = ?", sid, userID).First(&session).Error; err != nil { + return err + } + if session.Status != UserSessionStatusActive || session.ExpiresAt <= now || + session.Version != expectedSessionVersion || session.UserAuthVersion != expectedUserAuthVersion { + return ErrUserSessionInactive + } + session.Version++ + session.UserAuthVersion = nextUserAuthVersion + session.LastActiveAt = now + result := tx.Model(&UserSession{}). + Where("sid = ? AND status = ? AND version = ? AND user_auth_version = ?", sid, UserSessionStatusActive, expectedSessionVersion, expectedUserAuthVersion). + Updates(map[string]interface{}{ + "version": session.Version, + "user_auth_version": session.UserAuthVersion, + "last_active_at": session.LastActiveAt, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrUserSessionInactive + } + return nil + }) + if err != nil { + return nil, err + } + if err := writeUserSessionCache(session.cacheEntry()); err != nil { + return nil, err + } + return &session, nil +} + +func RevokeOtherUserSessions(userID int, currentSID, reason string) (int64, error) { + return revokeUserSessions(userID, currentSID, reason) +} + +func RevokeAllUserSessions(userID int, reason string) (int64, error) { + return revokeUserSessions(userID, "", reason) +} + +func revokeUserSessions(userID int, excludedSID, reason string) (int64, error) { + if userID <= 0 { + return 0, ErrUserSessionInvalid + } + now := time.Now().Unix() + query := DB.Where("user_id = ? AND status = ? AND expires_at > ?", userID, UserSessionStatusActive, now) + if excludedSID != "" { + query = query.Where("sid <> ?", excludedSID) + } + var candidates []UserSession + if err := query.Find(&candidates).Error; err != nil { + return 0, err + } + for i := range candidates { + if err := writeUserSessionDenyFence(&candidates[i], UserSessionStatusRevoking, now, reason); err != nil { + return 0, err + } + } + if len(candidates) == 0 { + return 0, nil + } + + sids := make([]string, 0, len(candidates)) + for i := range candidates { + sids = append(sids, candidates[i].SID) + } + var affected int64 + err := DB.Transaction(func(tx *gorm.DB) error { + var locked []UserSession + if err := lockForUpdate(tx).Where("sid IN ? AND status = ?", sids, UserSessionStatusActive).Find(&locked).Error; err != nil { + return err + } + if len(locked) == 0 { + return nil + } + lockedSIDs := make([]string, 0, len(locked)) + for i := range locked { + lockedSIDs = append(lockedSIDs, locked[i].SID) + } + result := tx.Model(&UserSession{}).Where("sid IN ? AND status = ?", lockedSIDs, UserSessionStatusActive).Updates(map[string]interface{}{ + "status": UserSessionStatusRevoked, + "revoked_at": now, + "revoked_reason": reason, + }) + affected = result.RowsAffected + return result.Error + }) + if err != nil { + return 0, err + } + for i := range candidates { + candidates[i].Status = UserSessionStatusRevoked + candidates[i].RevokedAt = now + candidates[i].RevokedReason = reason + if err := writeUserSessionCache(candidates[i].cacheEntry()); err != nil { + common.SysLog("failed to finalize bulk user session revoke tombstone: " + err.Error()) + } + } + return affected, nil +} + +func DeleteExpiredUserSessions(now int64) error { + if now <= 0 { + now = time.Now().Unix() + } + return DB.Where("expires_at < ?", now).Delete(&UserSession{}).Error +} diff --git a/model/user_session_test.go b/model/user_session_test.go new file mode 100644 index 000000000000..f35958500506 --- /dev/null +++ b/model/user_session_test.go @@ -0,0 +1,209 @@ +package model + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupUserSessionTest(t *testing.T) { + t.Helper() + require.NoError(t, DB.AutoMigrate(&UserSession{})) + require.NoError(t, DB.Exec("DELETE FROM user_sessions").Error) + oldRedisEnabled := common.RedisEnabled + common.RedisEnabled = false + t.Cleanup(func() { + common.RedisEnabled = oldRedisEnabled + }) +} + +func newTestUserSession(sid string, userID int, now int64) *UserSession { + return &UserSession{ + SID: sid, + UserID: userID, + Version: 1, + UserAuthVersion: 1, + Status: UserSessionStatusActive, + RefreshHash: fmt.Sprintf("current-%s", sid), + LoginMethod: "password", + IP: "127.0.0.1", + UserAgent: "model-test", + CreatedAt: now, + LastActiveAt: now, + ExpiresAt: now + int64((30*24*time.Hour)/time.Second), + } +} + +func TestUserSessionCreateListAndRevokeOne(t *testing.T) { + setupUserSessionTest(t) + now := time.Now().Unix() + user := User{Id: 1001, Username: "session-list-user", Password: "password", AuthVersion: 1} + require.NoError(t, DB.Create(&user).Error) + t.Cleanup(func() { _ = DB.Unscoped().Delete(&User{}, user.Id).Error }) + first := newTestUserSession("session-one", 1001, now) + second := newTestUserSession("session-two", 1001, now+1) + require.NoError(t, CreateUserSession(first)) + require.NoError(t, CreateUserSession(second)) + + sessions, err := ListActiveUserSessions(1001, now) + require.NoError(t, err) + require.Len(t, sessions, 2) + assert.Equal(t, second.SID, sessions[0].SID) + + revoked, err := RevokeUserSession(1001, first.SID, "user_revoked") + require.NoError(t, err) + assert.True(t, revoked) + revoked, err = RevokeUserSession(1001, first.SID, "duplicate") + require.NoError(t, err) + assert.False(t, revoked) + + _, err = GetUserSessionCached(first.SID) + assert.ErrorIs(t, err, ErrUserSessionInactive) + active, err := GetUserSessionCached(second.SID) + require.NoError(t, err) + assert.Equal(t, second.SID, active.SID) +} + +func TestRotateUserSessionRefreshRaceAndReuse(t *testing.T) { + setupUserSessionTest(t) + now := time.Now().Unix() + session := newTestUserSession("rotate-session", 1002, now) + require.NoError(t, CreateUserSession(session)) + + rotated, err := RotateUserSessionRefresh(1002, session.SID, session.RefreshHash, "next-hash", now+10, 30*time.Second) + require.NoError(t, err) + assert.Equal(t, "next-hash", rotated.RefreshHash) + assert.Equal(t, session.RefreshHash, rotated.PreviousRefreshHash) + assert.Equal(t, now+40, rotated.PreviousValidUntil) + + _, err = RotateUserSessionRefresh(1002, session.SID, session.RefreshHash, "unused-hash", now+20, 30*time.Second) + assert.ErrorIs(t, err, ErrUserSessionRefreshRace) + _, err = RotateUserSessionRefresh(1002, session.SID, "unknown-hash", "unused-hash", now+20, 30*time.Second) + assert.ErrorIs(t, err, ErrUserSessionRefreshInvalid) + stored, getErr := GetUserSessionBySID(session.SID) + require.NoError(t, getErr) + assert.Equal(t, UserSessionStatusActive, stored.Status) + + _, err = RotateUserSessionRefresh(1002, session.SID, session.RefreshHash, "unused-hash", now+41, 30*time.Second) + assert.ErrorIs(t, err, ErrUserSessionRefreshReuse) + stored, getErr = GetUserSessionBySID(session.SID) + require.NoError(t, getErr) + assert.Equal(t, UserSessionStatusRevoked, stored.Status) + assert.Equal(t, "refresh_reuse", stored.RevokedReason) +} + +func TestRevokeOtherUserSessionsKeepsCurrent(t *testing.T) { + setupUserSessionTest(t) + now := time.Now().Unix() + for _, sid := range []string{"current-session", "other-one", "other-two"} { + require.NoError(t, CreateUserSession(newTestUserSession(sid, 1003, now))) + } + require.NoError(t, CreateUserSession(newTestUserSession("different-user", 1004, now))) + + count, err := RevokeOtherUserSessions(1003, "current-session", "revoke_others") + require.NoError(t, err) + assert.Equal(t, int64(2), count) + + current, err := GetUserSessionCached("current-session") + require.NoError(t, err) + assert.Equal(t, UserSessionStatusActive, current.Status) + _, err = GetUserSessionCached("other-one") + assert.True(t, errors.Is(err, ErrUserSessionInactive)) + different, err := GetUserSessionCached("different-user") + require.NoError(t, err) + assert.Equal(t, 1004, different.UserID) +} + +func TestRevokeUserSessionByRefreshHashRequiresSecret(t *testing.T) { + setupUserSessionTest(t) + now := time.Now().Unix() + session := newTestUserSession("refresh-logout-session", 1005, now) + require.NoError(t, CreateUserSession(session)) + + revoked, err := RevokeUserSessionByRefreshHash(session.SID, "wrong-hash", "logout") + require.NoError(t, err) + assert.False(t, revoked) + active, err := GetUserSessionCached(session.SID) + require.NoError(t, err) + assert.Equal(t, UserSessionStatusActive, active.Status) + + revoked, err = RevokeUserSessionByRefreshHash(session.SID, session.RefreshHash, "logout") + require.NoError(t, err) + assert.True(t, revoked) + _, err = GetUserSessionCached(session.SID) + assert.ErrorIs(t, err, ErrUserSessionInactive) +} + +func TestUserBaseIncludesAuthorizationFields(t *testing.T) { + user := User{ + Id: 42, + Username: "cache-user", + Role: common.RoleAdminUser, + Status: common.UserStatusEnabled, + Group: "vip", + Quota: 123, + AuthVersion: 7, + } + base := user.ToBaseUser() + assert.Equal(t, user.Role, base.Role) + assert.Equal(t, user.AuthVersion, base.AuthVersion) + assert.Equal(t, userCacheSchemaVersion, base.CacheSchema) + assert.Equal(t, user.Quota, base.Quota) +} + +func TestUserUpdateBumpsAuthVersionOnlyForAuthorizationChanges(t *testing.T) { + setupUserSessionTest(t) + user := &User{ + Username: "auth-version-user", + Password: "hashed-placeholder", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + } + require.NoError(t, DB.Create(user).Error) + t.Cleanup(func() { _ = DB.Unscoped().Delete(&User{}, user.Id).Error }) + assert.Equal(t, int64(1), user.AuthVersion) + + user.DisplayName = "profile-only" + require.NoError(t, user.Update(false)) + assert.Equal(t, int64(1), user.AuthVersion) + + user.Group = "vip" + require.NoError(t, user.Update(false)) + assert.Equal(t, int64(2), user.AuthVersion) + + user.Role = common.RoleAdminUser + require.NoError(t, user.Update(false)) + assert.Equal(t, int64(3), user.AuthVersion) +} + +func TestPasswordResetBumpsAuthVersionAndRevokesSessions(t *testing.T) { + setupUserSessionTest(t) + now := time.Now().Unix() + user := &User{ + Username: "password-reset-user", + Password: "old-hash", + Email: "password-reset@example.com", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + } + require.NoError(t, DB.Create(user).Error) + t.Cleanup(func() { _ = DB.Unscoped().Delete(&User{}, user.Id).Error }) + session := newTestUserSession("password-reset-session", user.Id, now) + require.NoError(t, CreateUserSession(session)) + + require.NoError(t, ResetUserPasswordByEmail(user.Email, "new-password")) + var stored User + require.NoError(t, DB.First(&stored, user.Id).Error) + assert.Equal(t, int64(2), stored.AuthVersion) + storedSession, err := GetUserSessionBySID(session.SID) + require.NoError(t, err) + assert.Equal(t, UserSessionStatusRevoked, storedSession.Status) + assert.Equal(t, "password_reset", storedSession.RevokedReason) +} diff --git a/router/api-router.go b/router/api-router.go index 83f9259b2132..2cc5e9bdbf63 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -43,15 +43,16 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.ResetPassword) // OAuth routes - specific routes must come before :provider wildcard - apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode) - apiRouter.POST("/oauth/email/bind", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.EmailBind) + apiRouter.POST("/oauth/state", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.TryUserAuth(), anonymousRequestBodyLimit, controller.GenerateOAuthCode) + apiRouter.POST("/oauth/email/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.EmailBind) // Non-standard OAuth (WeChat, Telegram) - keep original routes - apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth) - apiRouter.POST("/oauth/wechat/bind", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.WeChatBind) - apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin) - apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind) + apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.WeChatAuth) + apiRouter.POST("/oauth/wechat/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.WeChatBind) + apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramLogin) + apiRouter.POST("/oauth/telegram/bind/start", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramBindStart) + apiRouter.GET("/oauth/telegram/bind/:flow_token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramBind) // Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route - apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth) + apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.TryUserAuth(), controller.HandleOAuth) apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) apiRouter.POST("/stripe/webhook", anonymousRequestBodyLimit, controller.StripeWebhook) @@ -62,17 +63,18 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/waffo-pancake/webhook/:env", anonymousRequestBodyLimit, controller.WaffoPancakeWebhook) // Universal secure verification routes - apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) + apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.UniversalVerify) userRoute := apiRouter.Group("/user") { + userRoute.POST("/auth/refresh", middleware.SessionCookieOriginGuard(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.RefreshAuth) + userRoute.POST("/auth/logout", middleware.SessionCookieOriginGuard(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.AuthLogout) userRoute.POST("/register", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Register) - userRoute.POST("/login", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Login) - userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.Verify2FALogin) - userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.PasskeyLoginBegin) - userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.PasskeyLoginFinish) + userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Login) + userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.Verify2FALogin) + userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.PasskeyLoginBegin) + userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.PasskeyLoginFinish) //userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog) - userRoute.GET("/logout", controller.Logout) userRoute.POST("/epay/notify", anonymousRequestBodyLimit, controller.EpayNotify) userRoute.GET("/epay/notify", controller.EpayNotify) userRoute.GET("/groups", controller.GetUserGroups) @@ -80,18 +82,21 @@ func SetApiRouter(router *gin.Engine) { selfRoute := userRoute.Group("/") selfRoute.Use(middleware.UserAuth()) { + selfRoute.GET("/sessions", middleware.DisableCache(), controller.GetLoginSessions) + selfRoute.DELETE("/sessions/:sid", middleware.DisableCache(), controller.DeleteLoginSession) + selfRoute.POST("/sessions/revoke-others", middleware.DisableCache(), controller.RevokeOtherLoginSessions) selfRoute.GET("/self/groups", controller.GetUserGroups) selfRoute.GET("/self", controller.GetSelf) selfRoute.GET("/models", controller.GetUserModels) - selfRoute.PUT("/self", middleware.CriticalRateLimit(), controller.UpdateSelf) + selfRoute.PUT("/self", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.UpdateSelf) selfRoute.DELETE("/self", controller.DeleteSelf) - selfRoute.GET("/token", controller.GenerateAccessToken) + selfRoute.GET("/token", middleware.DisableCache(), controller.GenerateAccessToken) selfRoute.GET("/passkey", controller.PasskeyStatus) - selfRoute.POST("/passkey/register/begin", controller.PasskeyRegisterBegin) - selfRoute.POST("/passkey/register/finish", controller.PasskeyRegisterFinish) - selfRoute.POST("/passkey/verify/begin", controller.PasskeyVerifyBegin) - selfRoute.POST("/passkey/verify/finish", controller.PasskeyVerifyFinish) - selfRoute.DELETE("/passkey", controller.PasskeyDelete) + selfRoute.POST("/passkey/register/begin", middleware.DisableCache(), controller.PasskeyRegisterBegin) + selfRoute.POST("/passkey/register/finish", middleware.DisableCache(), controller.PasskeyRegisterFinish) + selfRoute.POST("/passkey/verify/begin", middleware.DisableCache(), controller.PasskeyVerifyBegin) + selfRoute.POST("/passkey/verify/finish", middleware.DisableCache(), controller.PasskeyVerifyFinish) + selfRoute.DELETE("/passkey", middleware.DisableCache(), controller.PasskeyDelete) selfRoute.GET("/aff", controller.GetAffCode) selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.GET("/topup/self", controller.GetUserTopUps) @@ -110,10 +115,10 @@ func SetApiRouter(router *gin.Engine) { // 2FA routes selfRoute.GET("/2fa/status", controller.Get2FAStatus) - selfRoute.POST("/2fa/setup", controller.Setup2FA) - selfRoute.POST("/2fa/enable", controller.Enable2FA) - selfRoute.POST("/2fa/disable", controller.Disable2FA) - selfRoute.POST("/2fa/backup_codes", controller.RegenerateBackupCodes) + selfRoute.POST("/2fa/setup", middleware.DisableCache(), controller.Setup2FA) + selfRoute.POST("/2fa/enable", middleware.DisableCache(), controller.Enable2FA) + selfRoute.POST("/2fa/disable", middleware.DisableCache(), controller.Disable2FA) + selfRoute.POST("/2fa/backup_codes", middleware.DisableCache(), controller.RegenerateBackupCodes) // Check-in routes selfRoute.GET("/checkin", controller.GetCheckinStatus) diff --git a/service/auth_cleanup.go b/service/auth_cleanup.go new file mode 100644 index 000000000000..f4d4ff665159 --- /dev/null +++ b/service/auth_cleanup.go @@ -0,0 +1,36 @@ +package service + +import ( + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" +) + +const authArtifactCleanupInterval = time.Hour + +// StartAuthArtifactCleanup removes expired dashboard Sessions and old +// one-time authentication flows. Only the master instance performs cleanup. +func StartAuthArtifactCleanup() { + if !common.IsMasterNode { + return + } + go func() { + cleanupAuthArtifacts() + ticker := time.NewTicker(authArtifactCleanupInterval) + defer ticker.Stop() + for range ticker.C { + cleanupAuthArtifacts() + } + }() +} + +func cleanupAuthArtifacts() { + now := time.Now() + if err := model.DeleteExpiredUserSessions(now.Unix()); err != nil { + common.SysError("failed to delete expired user sessions: " + err.Error()) + } + if err := model.DeleteExpiredAuthFlows(now); err != nil { + common.SysError("failed to delete expired authentication flows: " + err.Error()) + } +} diff --git a/service/auth_session.go b/service/auth_session.go new file mode 100644 index 000000000000..8a28be711998 --- /dev/null +++ b/service/auth_session.go @@ -0,0 +1,400 @@ +package service + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const RefreshCookieName = "new_api_refresh" + +var ( + ErrLoginSessionInvalid = errors.New("login session is invalid") + ErrLoginSessionRevoked = errors.New("login session is revoked") + ErrLoginSessionMismatch = errors.New("login session does not match the expected session") + ErrRefreshTokenInvalid = errors.New("refresh token is invalid") + ErrRefreshRace = errors.New("refresh token was already rotated") +) + +type LoginSessionView struct { + SID string `json:"sid"` + Current bool `json:"current"` + LoginMethod string `json:"login_method"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + CreatedAt int64 `json:"created_at"` + LastActiveAt int64 `json:"last_active_at"` + ExpiresAt int64 `json:"expires_at"` +} + +type AuthBundle struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + AccessExpiresAt int64 `json:"access_expires_at"` + Session LoginSessionView `json:"session"` + RefreshToken string `json:"-"` +} + +func CreateLoginSession(userID int, loginMethod, ip, userAgent string) (*AuthBundle, error) { + return createLoginSession(userID, 0, loginMethod, ip, userAgent) +} + +func CreateLoginSessionAtAuthVersion(userID int, expectedAuthVersion int64, loginMethod, ip, userAgent string) (*AuthBundle, error) { + if expectedAuthVersion <= 0 { + return nil, ErrLoginSessionInvalid + } + return createLoginSession(userID, expectedAuthVersion, loginMethod, ip, userAgent) +} + +func createLoginSession(userID int, expectedAuthVersion int64, loginMethod, ip, userAgent string) (*AuthBundle, error) { + user, err := model.GetUserCache(userID) + if err != nil { + return nil, err + } + if user.Status != common.UserStatusEnabled || user.AuthVersion <= 0 { + return nil, ErrLoginSessionInvalid + } + if expectedAuthVersion > 0 && user.AuthVersion != expectedAuthVersion { + return nil, ErrLoginSessionRevoked + } + refreshSecret, err := common.GenerateRandomCharsKey(64) + if err != nil { + return nil, err + } + now := time.Now().Unix() + session := &model.UserSession{ + SID: uuid.NewString(), + UserID: userID, + Version: 1, + UserAuthVersion: user.AuthVersion, + Status: model.UserSessionStatusActive, + RefreshHash: hashRefreshSecret(refreshSecret), + LoginMethod: strings.TrimSpace(loginMethod), + IP: truncateAuthMetadata(ip, 64), + UserAgent: truncateAuthMetadata(userAgent, 512), + CreatedAt: now, + LastActiveAt: now, + ExpiresAt: time.Unix(now, 0).Add(LoginSessionTTL).Unix(), + } + if session.LoginMethod == "" { + session.LoginMethod = "unknown" + } + if err := model.CreateUserSession(session); err != nil { + return nil, err + } + bundle, err := issueAuthBundle(session, session.SID+"."+refreshSecret, true) + if err != nil { + _, _ = model.RevokeUserSession(userID, session.SID, "token_issue_failed") + return nil, err + } + return bundle, nil +} + +func ValidateLoginSession(identity AuthIdentity) (*model.UserSession, *model.UserBase, error) { + session, err := model.GetUserSessionCached(identity.SessionID) + if err != nil { + if errors.Is(err, model.ErrUserSessionInactive) { + return nil, nil, ErrLoginSessionRevoked + } + return nil, nil, err + } + now := time.Now().Unix() + if session.UserID != identity.UserID || session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now || session.Version != identity.SessionVersion || session.UserAuthVersion != identity.UserAuthVersion { + return nil, nil, ErrLoginSessionRevoked + } + user, err := model.GetUserCache(identity.UserID) + if err != nil { + return nil, nil, err + } + if user.Status != common.UserStatusEnabled || user.AuthVersion != identity.UserAuthVersion { + return nil, nil, ErrLoginSessionRevoked + } + return session, user, nil +} + +// ValidateSessionReference validates a server-side flow bound to an existing +// dashboard session without requiring an access token on the callback request. +func ValidateSessionReference(userID int, sid string) (AuthIdentity, error) { + if userID <= 0 || strings.TrimSpace(sid) == "" { + return AuthIdentity{}, ErrLoginSessionInvalid + } + session, err := model.GetUserSessionCached(sid) + if err != nil { + return AuthIdentity{}, err + } + identity := AuthIdentity{ + UserID: userID, + SessionID: sid, + UserAuthVersion: session.UserAuthVersion, + SessionVersion: session.Version, + } + if _, _, err := ValidateLoginSession(identity); err != nil { + return AuthIdentity{}, err + } + return identity, nil +} + +// AdvanceCurrentSessionSecurity increments the user's global auth version, +// preserves only the current browser session at a new session version and +// returns a replacement access token. Call after a successful 2FA/passkey +// security-setting mutation that did not already advance AuthVersion. +func AdvanceCurrentSessionSecurity(identity AuthIdentity, reason string) (*AuthBundle, error) { + nextUserAuthVersion, err := model.BumpUserAuthVersion(identity.UserID) + if err != nil { + return nil, err + } + return advanceCurrentSessionToVersion(identity, nextUserAuthVersion, reason) +} + +// AdvanceCurrentSessionToUserVersion is used when the security mutation and +// AuthVersion increment were committed in the same transaction (for example, +// a password change). +func AdvanceCurrentSessionToUserVersion(identity AuthIdentity, reason string) (*AuthBundle, error) { + user, err := model.GetUserCache(identity.UserID) + if err != nil { + return nil, err + } + if user.Status != common.UserStatusEnabled || user.AuthVersion <= identity.UserAuthVersion { + return nil, ErrLoginSessionRevoked + } + return advanceCurrentSessionToVersion(identity, user.AuthVersion, reason) +} + +func advanceCurrentSessionToVersion(identity AuthIdentity, nextUserAuthVersion int64, reason string) (*AuthBundle, error) { + session, err := model.AdvanceUserSessionAuthVersion( + identity.UserID, + identity.SessionID, + identity.SessionVersion, + identity.UserAuthVersion, + nextUserAuthVersion, + ) + if err != nil { + return nil, err + } + if _, err := model.RevokeOtherUserSessions(identity.UserID, identity.SessionID, reason); err != nil { + return nil, err + } + return issueAuthBundle(session, "", true) +} + +func RefreshLoginSession(rawRefreshToken, expectedSID, ip, userAgent string) (*AuthBundle, *model.User, error) { + sid, secret, ok := splitRefreshToken(rawRefreshToken) + if !ok { + return nil, nil, ErrRefreshTokenInvalid + } + if expectedSID = strings.TrimSpace(expectedSID); expectedSID != "" && expectedSID != sid { + return nil, nil, ErrLoginSessionMismatch + } + session, err := model.GetUserSessionCached(sid) + if err != nil { + if errors.Is(err, model.ErrUserSessionInactive) { + return nil, nil, ErrLoginSessionRevoked + } + return nil, nil, ErrRefreshTokenInvalid + } + if session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= time.Now().Unix() { + return nil, nil, ErrLoginSessionRevoked + } + userCache, err := model.GetUserCache(session.UserID) + if err != nil { + return nil, nil, err + } + currentUser, err := model.GetUserById(session.UserID, false) + if err != nil { + return nil, nil, err + } + if userCache.Status != common.UserStatusEnabled || userCache.AuthVersion != session.UserAuthVersion || + currentUser.Status != common.UserStatusEnabled || currentUser.AuthVersion != session.UserAuthVersion { + _, _ = model.RevokeUserSession(session.UserID, session.SID, "user_security_changed") + return nil, nil, ErrLoginSessionRevoked + } + nextSecret := deriveNextRefreshSecret(sid, secret) + rotated, err := model.RotateUserSessionRefresh(session.UserID, sid, hashRefreshSecret(secret), hashRefreshSecret(nextSecret), time.Now().Unix(), RefreshReplayWindow) + if err != nil { + if errors.Is(err, model.ErrUserSessionRefreshRace) && rotated != nil && + hashRefreshSecret(nextSecret) == rotated.RefreshHash { + bundle, issueErr := issueAuthBundle(rotated, sid+"."+nextSecret, true) + if issueErr != nil { + return nil, nil, issueErr + } + return bundle, currentUser, nil + } + if errors.Is(err, model.ErrUserSessionRefreshReuse) { + return nil, nil, ErrLoginSessionRevoked + } + if errors.Is(err, model.ErrUserSessionRefreshInvalid) { + return nil, nil, ErrRefreshTokenInvalid + } + if errors.Is(err, model.ErrUserSessionRefreshRace) { + return nil, nil, ErrRefreshRace + } + return nil, nil, err + } + rotated.IP = truncateAuthMetadata(ip, 64) + rotated.UserAgent = truncateAuthMetadata(userAgent, 512) + bundle, err := issueAuthBundle(rotated, sid+"."+nextSecret, true) + if err != nil { + return nil, nil, err + } + return bundle, currentUser, nil +} + +func RevokeByRefreshToken(rawRefreshToken, expectedSID, reason string) error { + sid, secret, ok := splitRefreshToken(rawRefreshToken) + if !ok { + return nil + } + if expectedSID = strings.TrimSpace(expectedSID); expectedSID != "" && expectedSID != sid { + return ErrLoginSessionMismatch + } + _, err := model.RevokeUserSessionByRefreshHash(sid, hashRefreshSecret(secret), reason) + return err +} + +func RefreshTokenSID(rawRefreshToken string) (string, bool) { + sid, _, ok := splitRefreshToken(rawRefreshToken) + return sid, ok +} + +func ListLoginSessions(userID int, currentSID string) ([]LoginSessionView, error) { + sessions, err := model.ListActiveUserSessions(userID, time.Now().Unix()) + if err != nil { + return nil, err + } + views := make([]LoginSessionView, 0, len(sessions)) + for i := range sessions { + views = append(views, sessionView(&sessions[i], sessions[i].SID == currentSID)) + } + return views, nil +} + +func WriteRefreshCookie(c *gin.Context, rawToken string) { + expiresAt := time.Now().Add(LoginSessionTTL) + if sid, _, ok := splitRefreshToken(rawToken); ok { + if session, err := model.GetUserSessionCached(sid); err == nil && session.ExpiresAt > time.Now().Unix() { + expiresAt = time.Unix(session.ExpiresAt, 0) + } + } + maxAge := int(time.Until(expiresAt) / time.Second) + if maxAge < 1 { + maxAge = 1 + } + http.SetCookie(c.Writer, &http.Cookie{ + Name: RefreshCookieName, + Value: rawToken, + Path: "/api/user/auth", + MaxAge: maxAge, + Expires: expiresAt, + HttpOnly: true, + Secure: common.SessionCookieSecure, + SameSite: http.SameSiteStrictMode, + }) +} + +func ClearRefreshCookie(c *gin.Context) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: RefreshCookieName, + Value: "", + Path: "/api/user/auth", + MaxAge: -1, + Expires: time.Unix(1, 0), + HttpOnly: true, + Secure: common.SessionCookieSecure, + SameSite: http.SameSiteStrictMode, + }) +} + +func issueAuthBundle(session *model.UserSession, rawRefreshToken string, current bool) (*AuthBundle, error) { + identity := AuthIdentity{ + UserID: session.UserID, + SessionID: session.SID, + UserAuthVersion: session.UserAuthVersion, + SessionVersion: session.Version, + } + accessToken, accessExpiresAt, err := IssueAccessToken(identity) + if err != nil { + return nil, err + } + return &AuthBundle{ + AccessToken: accessToken, + TokenType: "Bearer", + AccessExpiresAt: accessExpiresAt, + Session: sessionView(session, current), + RefreshToken: rawRefreshToken, + }, nil +} + +func sessionView(session *model.UserSession, current bool) LoginSessionView { + return LoginSessionView{ + SID: session.SID, + Current: current, + LoginMethod: session.LoginMethod, + IP: session.IP, + UserAgent: session.UserAgent, + CreatedAt: session.CreatedAt, + LastActiveAt: session.LastActiveAt, + ExpiresAt: session.ExpiresAt, + } +} + +func splitRefreshToken(raw string) (string, string, bool) { + sid, secret, ok := strings.Cut(strings.TrimSpace(raw), ".") + if !ok || sid == "" || secret == "" || strings.Contains(secret, ".") { + return "", "", false + } + if _, err := uuid.Parse(sid); err != nil { + return "", "", false + } + return sid, secret, true +} + +func hashRefreshSecret(secret string) string { + return common.GenerateHMACWithKey(authSigningKey("refresh"), secret) +} + +func deriveNextRefreshSecret(sid, currentSecret string) string { + return common.GenerateHMACWithKey(authSigningKey("refresh-rotate"), sid+"."+currentSecret) +} + +func truncateAuthMetadata(value string, max int) string { + value = strings.TrimSpace(value) + if len(value) <= max { + return value + } + return value[:max] +} + +func authSessionErrorCode(err error) (int, string) { + switch { + case errors.Is(err, ErrLoginSessionMismatch): + return http.StatusConflict, "AUTH_SESSION_MISMATCH" + case errors.Is(err, ErrRefreshRace): + return http.StatusConflict, "AUTH_REFRESH_RACE" + case errors.Is(err, ErrAuthTokenExpired): + return http.StatusUnauthorized, "AUTH_TOKEN_EXPIRED" + case errors.Is(err, ErrLoginSessionRevoked): + return http.StatusUnauthorized, "AUTH_SESSION_REVOKED" + case errors.Is(err, ErrRefreshTokenInvalid), errors.Is(err, ErrAuthTokenInvalid): + return http.StatusUnauthorized, "AUTH_UNAUTHORIZED" + default: + return http.StatusInternalServerError, "AUTH_INTERNAL_ERROR" + } +} + +func AuthSessionErrorCode(err error) (int, string) { + return authSessionErrorCode(err) +} + +func FormatAuthError(err error) string { + if err == nil { + return "" + } + return fmt.Sprintf("authentication failed: %v", err) +} diff --git a/service/auth_session_test.go b/service/auth_session_test.go new file mode 100644 index 000000000000..3c2a9a34216d --- /dev/null +++ b/service/auth_session_test.go @@ -0,0 +1,118 @@ +package service + +import ( + "errors" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupAuthSessionTestDB(t *testing.T) *model.User { + t.Helper() + previousDB, previousRedis := model.DB, common.RedisEnabled + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.AuthFlow{})) + model.DB = db + common.RedisEnabled = false + t.Cleanup(func() { + model.DB = previousDB + common.RedisEnabled = previousRedis + _ = sqlDB.Close() + }) + user := &model.User{ + Username: "session-user", + Password: "unused-password-hash", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + AuthVersion: 1, + } + require.NoError(t, db.Create(user).Error) + return user +} + +func TestCleanupAuthArtifactsRemovesOnlyExpiredRecords(t *testing.T) { + setupAuthSessionTestDB(t) + now := time.Now() + oldExpiry := now.Add(-25 * time.Hour) + require.NoError(t, model.DB.Create(&model.UserSession{ + SID: "expired-session", UserID: 1, Version: 1, UserAuthVersion: 1, + Status: model.UserSessionStatusActive, RefreshHash: "hash", LoginMethod: "password", + LastActiveAt: oldExpiry.Unix(), ExpiresAt: oldExpiry.Unix(), + }).Error) + require.NoError(t, model.DB.Create(&model.AuthFlow{ + TokenHash: "expired-flow", Purpose: model.AuthFlowPurposeTwoFALogin, + ExpiresAt: oldExpiry, + }).Error) + require.NoError(t, model.DB.Create(&model.AuthFlow{ + TokenHash: "recent-flow", Purpose: model.AuthFlowPurposeTwoFALogin, + ExpiresAt: now.Add(time.Minute), + }).Error) + + cleanupAuthArtifacts() + + var sessionCount int64 + require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&sessionCount).Error) + assert.Zero(t, sessionCount) + var flows []model.AuthFlow + require.NoError(t, model.DB.Find(&flows).Error) + require.Len(t, flows, 1) + assert.Equal(t, "recent-flow", flows[0].TokenHash) +} + +func TestLoginSessionCreateRefreshAndRevoke(t *testing.T) { + useTestSessionSecret(t) + user := setupAuthSessionTestDB(t) + + bundle, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent") + require.NoError(t, err) + assert.NotEmpty(t, bundle.RefreshToken) + identity, err := ParseAccessToken(bundle.AccessToken) + require.NoError(t, err) + _, cachedUser, err := ValidateLoginSession(identity) + require.NoError(t, err) + assert.Equal(t, user.Id, cachedUser.Id) + require.NoError(t, RevokeByRefreshToken(bundle.Session.SID+".wrong-refresh-secret", "", "logout")) + _, _, err = ValidateLoginSession(identity) + require.NoError(t, err, "a caller that only knows sid must not be able to revoke the session") + + refreshed, _, err := RefreshLoginSession(bundle.RefreshToken, bundle.Session.SID, "127.0.0.2", "test-agent-2") + require.NoError(t, err) + assert.NotEqual(t, bundle.RefreshToken, refreshed.RefreshToken) + recovered, _, err := RefreshLoginSession(bundle.RefreshToken, bundle.Session.SID, "127.0.0.2", "test-agent-2") + require.NoError(t, err) + assert.Equal(t, refreshed.RefreshToken, recovered.RefreshToken, "a concurrent refresh must recover the winner's rotated token") + + _, _, err = RefreshLoginSession(refreshed.RefreshToken, "different-session", "127.0.0.2", "test-agent-2") + assert.ErrorIs(t, err, ErrLoginSessionMismatch) + + require.NoError(t, RevokeByRefreshToken(refreshed.RefreshToken, refreshed.Session.SID, "logout")) + _, _, err = ValidateLoginSession(identity) + assert.True(t, errors.Is(err, ErrLoginSessionRevoked)) +} + +func TestUserAuthVersionInvalidatesExistingSession(t *testing.T) { + useTestSessionSecret(t) + user := setupAuthSessionTestDB(t) + bundle, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent") + require.NoError(t, err) + identity, err := ParseAccessToken(bundle.AccessToken) + require.NoError(t, err) + + _, err = model.BumpUserAuthVersion(user.Id) + require.NoError(t, err) + _, _, err = ValidateLoginSession(identity) + assert.ErrorIs(t, err, ErrLoginSessionRevoked) + _, err = CreateLoginSessionAtAuthVersion(user.Id, identity.UserAuthVersion, "2fa", "127.0.0.1", "test-agent") + assert.ErrorIs(t, err, ErrLoginSessionRevoked, "a pending 2FA flow must not survive an auth-version change") +} diff --git a/service/auth_token.go b/service/auth_token.go new file mode 100644 index 000000000000..309e8dd086c3 --- /dev/null +++ b/service/auth_token.go @@ -0,0 +1,216 @@ +package service + +import ( + "crypto/hmac" + "crypto/sha256" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +const ( + AccessTokenTTL = 15 * time.Minute + SecurityProofTTL = 5 * time.Minute + LoginSessionTTL = 30 * 24 * time.Hour + RefreshReplayWindow = 30 * time.Second + accessTokenUse = "access" + securityProofTokenUse = "security_proof" + authTokenIssuer = "new-api" + authTokenAudience = "new-api-dashboard" +) + +var ( + ErrAuthTokenInvalid = errors.New("authentication token is invalid") + ErrAuthTokenExpired = errors.New("authentication token has expired") + ErrProofScope = errors.New("security proof scope mismatch") + ErrProofMethod = errors.New("security proof method mismatch") +) + +// AuthIdentity is the server-validated identity attached to dashboard requests. +// Role, status and group are deliberately loaded from the user cache instead of JWT claims. +type AuthIdentity struct { + UserID int + SessionID string + UserAuthVersion int64 + SessionVersion int64 +} + +type authClaims struct { + TokenUse string `json:"token_use"` + SessionID string `json:"sid"` + UserAuthVersion int64 `json:"uv"` + SessionVersion int64 `json:"sv"` + Method string `json:"method,omitempty"` + Scopes []string `json:"scopes,omitempty"` + jwt.RegisteredClaims +} + +func authSigningKey(purpose string) []byte { + mac := hmac.New(sha256.New, []byte(common.SessionSecret)) + _, _ = mac.Write([]byte("new-api/auth/" + purpose + "/v1")) + return mac.Sum(nil) +} + +func IssueAccessToken(identity AuthIdentity) (string, int64, error) { + if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 { + return "", 0, ErrAuthTokenInvalid + } + now := time.Now() + expiresAt := now.Add(AccessTokenTTL) + claims := authClaims{ + TokenUse: accessTokenUse, + SessionID: identity.SessionID, + UserAuthVersion: identity.UserAuthVersion, + SessionVersion: identity.SessionVersion, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: authTokenIssuer, + Subject: strconv.Itoa(identity.UserID), + Audience: jwt.ClaimStrings{authTokenAudience}, + ExpiresAt: jwt.NewNumericDate(expiresAt), + NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Second)), + IssuedAt: jwt.NewNumericDate(now), + ID: uuid.NewString(), + }, + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(authSigningKey(accessTokenUse)) + return signed, expiresAt.Unix(), err +} + +func ParseAccessToken(raw string) (AuthIdentity, error) { + claims, err := parseAuthClaims(raw, accessTokenUse, authSigningKey(accessTokenUse)) + if err != nil { + return AuthIdentity{}, err + } + userID, err := strconv.Atoi(claims.Subject) + if err != nil || userID <= 0 || claims.SessionID == "" || claims.UserAuthVersion <= 0 || claims.SessionVersion <= 0 { + return AuthIdentity{}, ErrAuthTokenInvalid + } + return AuthIdentity{ + UserID: userID, + SessionID: claims.SessionID, + UserAuthVersion: claims.UserAuthVersion, + SessionVersion: claims.SessionVersion, + }, nil +} + +// ParseDashboardAccessToken distinguishes new-api dashboard JWTs from opaque +// credentials. A token carrying the dashboard issuer, audience and a known +// token use is always treated as internal, even when its signature, lifetime +// or requested purpose is invalid, so it can never fall through to PAT or +// relay-token authentication. +func ParseDashboardAccessToken(raw string) (identity AuthIdentity, internal bool, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return AuthIdentity{}, false, nil + } + claims := &authClaims{} + parsed, _, parseErr := jwt.NewParser().ParseUnverified(raw, claims) + if parseErr != nil || parsed == nil { + return AuthIdentity{}, false, nil + } + audienceMatches := false + for _, audience := range claims.Audience { + if audience == authTokenAudience { + audienceMatches = true + break + } + } + knownTokenUse := claims.TokenUse == accessTokenUse || claims.TokenUse == securityProofTokenUse + if claims.Issuer != authTokenIssuer || !audienceMatches || !knownTokenUse { + return AuthIdentity{}, false, nil + } + identity, err = ParseAccessToken(raw) + return identity, true, err +} + +func IssueSecurityProof(identity AuthIdentity, method string, scopes []string) (string, int64, error) { + method = strings.TrimSpace(method) + if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 || method == "" || len(scopes) == 0 { + return "", 0, ErrAuthTokenInvalid + } + now := time.Now() + expiresAt := now.Add(SecurityProofTTL) + claims := authClaims{ + TokenUse: securityProofTokenUse, + SessionID: identity.SessionID, + UserAuthVersion: identity.UserAuthVersion, + SessionVersion: identity.SessionVersion, + Method: method, + Scopes: append([]string(nil), scopes...), + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: authTokenIssuer, + Subject: strconv.Itoa(identity.UserID), + Audience: jwt.ClaimStrings{authTokenAudience}, + ExpiresAt: jwt.NewNumericDate(expiresAt), + NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Second)), + IssuedAt: jwt.NewNumericDate(now), + ID: uuid.NewString(), + }, + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(authSigningKey(securityProofTokenUse)) + return signed, expiresAt.Unix(), err +} + +func VerifySecurityProof(raw string, identity AuthIdentity, requiredScope string, allowedMethods []string) (string, error) { + claims, err := parseAuthClaims(raw, securityProofTokenUse, authSigningKey(securityProofTokenUse)) + if err != nil { + return "", err + } + userID, err := strconv.Atoi(claims.Subject) + if err != nil || userID != identity.UserID || claims.SessionID != identity.SessionID || claims.UserAuthVersion != identity.UserAuthVersion || claims.SessionVersion != identity.SessionVersion { + return "", ErrAuthTokenInvalid + } + methodAllowed := len(allowedMethods) == 0 + for _, method := range allowedMethods { + if hmac.Equal([]byte(claims.Method), []byte(method)) { + methodAllowed = true + break + } + } + if !methodAllowed { + return "", ErrProofMethod + } + if requiredScope != "" { + found := false + for _, scope := range claims.Scopes { + if hmac.Equal([]byte(scope), []byte(requiredScope)) { + found = true + break + } + } + if !found { + return "", ErrProofScope + } + } + return claims.Method, nil +} + +func parseAuthClaims(raw, expectedUse string, key []byte) (*authClaims, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, ErrAuthTokenInvalid + } + claims := &authClaims{} + parsed, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) { + if token.Method.Alg() != jwt.SigningMethodHS256.Alg() { + return nil, fmt.Errorf("%w: unexpected signing method", ErrAuthTokenInvalid) + } + return key, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(authTokenIssuer), jwt.WithAudience(authTokenAudience), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithLeeway(5*time.Second)) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrAuthTokenExpired + } + return nil, fmt.Errorf("%w: %v", ErrAuthTokenInvalid, err) + } + if !parsed.Valid || claims.TokenUse != expectedUse || claims.ID == "" || claims.IssuedAt == nil || claims.NotBefore == nil { + return nil, ErrAuthTokenInvalid + } + return claims, nil +} diff --git a/service/auth_token_test.go b/service/auth_token_test.go new file mode 100644 index 000000000000..e99573576a93 --- /dev/null +++ b/service/auth_token_test.go @@ -0,0 +1,140 @@ +package service + +import ( + "errors" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func useTestSessionSecret(t *testing.T) { + t.Helper() + previous := common.SessionSecret + common.SessionSecret = "test-session-secret-with-sufficient-entropy" + t.Cleanup(func() { common.SessionSecret = previous }) +} + +func TestAccessTokenRoundTripAndPurposeIsolation(t *testing.T) { + useTestSessionSecret(t) + identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2} + + token, expiresAt, err := IssueAccessToken(identity) + require.NoError(t, err) + assert.Positive(t, expiresAt) + + parsed, err := ParseAccessToken(token) + require.NoError(t, err) + assert.Equal(t, identity, parsed) + + proof, _, err := IssueSecurityProof(identity, "2fa", []string{"channel.key.read"}) + require.NoError(t, err) + _, err = ParseAccessToken(proof) + assert.ErrorIs(t, err, ErrAuthTokenInvalid) +} + +func TestAccessTokenRejectsTampering(t *testing.T) { + useTestSessionSecret(t) + identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 1, SessionVersion: 1} + token, _, err := IssueAccessToken(identity) + require.NoError(t, err) + + tamperAt := len(token) - 2 + replacement := "x" + if token[tamperAt] == 'x' { + replacement = "y" + } + tampered := token[:tamperAt] + replacement + token[tamperAt+1:] + _, err = ParseAccessToken(tampered) + assert.ErrorIs(t, err, ErrAuthTokenInvalid) + + _, internal, err := ParseDashboardAccessToken(tampered) + assert.True(t, internal) + assert.ErrorIs(t, err, ErrAuthTokenInvalid) +} + +func TestDashboardAccessTokenClassification(t *testing.T) { + useTestSessionSecret(t) + + identity, internal, err := ParseDashboardAccessToken("opaque.key.with-dots") + require.NoError(t, err) + assert.False(t, internal) + assert.Empty(t, identity) + + external := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": "external-issuer", + "aud": authTokenAudience, + "exp": time.Now().Add(time.Minute).Unix(), + }) + externalRaw, err := external.SignedString([]byte("external-secret")) + require.NoError(t, err) + _, internal, err = ParseDashboardAccessToken(externalRaw) + require.NoError(t, err) + assert.False(t, internal) + + unknownUse := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": authTokenIssuer, + "aud": authTokenAudience, + "token_use": "third_party", + "exp": time.Now().Add(time.Minute).Unix(), + }) + unknownUseRaw, err := unknownUse.SignedString([]byte("external-secret")) + require.NoError(t, err) + _, internal, err = ParseDashboardAccessToken(unknownUseRaw) + require.NoError(t, err) + assert.False(t, internal) + + proof, _, err := IssueSecurityProof(AuthIdentity{ + UserID: 42, SessionID: "session-1", UserAuthVersion: 1, SessionVersion: 1, + }, "2fa", []string{"channel.key.read"}) + require.NoError(t, err) + _, internal, err = ParseDashboardAccessToken(proof) + assert.True(t, internal) + assert.ErrorIs(t, err, ErrAuthTokenInvalid) + + expiredClaims := authClaims{ + TokenUse: accessTokenUse, + SessionID: "expired-session", + UserAuthVersion: 1, + SessionVersion: 1, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: authTokenIssuer, + Subject: "42", + Audience: jwt.ClaimStrings{authTokenAudience}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)), + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Minute)), + ID: "expired-token", + }, + } + expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, expiredClaims).SignedString(authSigningKey(accessTokenUse)) + require.NoError(t, err) + _, internal, err = ParseDashboardAccessToken(expired) + assert.True(t, internal) + assert.ErrorIs(t, err, ErrAuthTokenExpired) +} + +func TestSecurityProofBindsIdentityMethodAndScope(t *testing.T) { + useTestSessionSecret(t) + identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2} + proof, _, err := IssueSecurityProof(identity, "2fa", []string{"channel.key.read"}) + require.NoError(t, err) + + method, err := VerifySecurityProof(proof, identity, "channel.key.read", []string{"2fa", "passkey"}) + require.NoError(t, err) + assert.Equal(t, "2fa", method) + + _, err = VerifySecurityProof(proof, identity, "passkey.delete", []string{"2fa"}) + assert.ErrorIs(t, err, ErrProofScope) + + _, err = VerifySecurityProof(proof, identity, "channel.key.read", []string{"passkey"}) + assert.ErrorIs(t, err, ErrProofMethod) + + otherSession := identity + otherSession.SessionID = "session-2" + _, err = VerifySecurityProof(proof, otherSession, "channel.key.read", []string{"2fa"}) + assert.True(t, errors.Is(err, ErrAuthTokenInvalid)) +} diff --git a/service/passkey/service.go b/service/passkey/service.go index 4d29d1aefa62..243eae3f6354 100644 --- a/service/passkey/service.go +++ b/service/passkey/service.go @@ -16,12 +16,6 @@ import ( webauthn "github.com/go-webauthn/webauthn/webauthn" ) -const ( - RegistrationSessionKey = "passkey_registration_session" - LoginSessionKey = "passkey_login_session" - VerifySessionKey = "passkey_verify_session" -) - // BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context. func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) { settings := system_setting.GetPasskeySettings() diff --git a/service/passkey/session.go b/service/passkey/session.go index 15e61932690f..3753f5158e68 100644 --- a/service/passkey/session.go +++ b/service/passkey/session.go @@ -1,50 +1,61 @@ package passkey import ( - "encoding/json" "errors" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" webauthn "github.com/go-webauthn/webauthn/webauthn" ) var errSessionNotFound = errors.New("Passkey 会话不存在或已过期") -func SaveSessionData(c *gin.Context, key string, data *webauthn.SessionData) error { - session := sessions.Default(c) +const passkeyFlowTTL = 5 * time.Minute + +type flowPayload struct { + SessionData webauthn.SessionData `json:"session_data"` + Scope string `json:"scope,omitempty"` +} + +func CreateSessionDataFlow(purpose string, userID int, sessionID, scope string, data *webauthn.SessionData) (string, int64, error) { if data == nil { - session.Delete(key) - return session.Save() + return "", 0, errors.New("Passkey 会话数据不能为空") + } + payload, err := common.Marshal(flowPayload{SessionData: *data, Scope: scope}) + if err != nil { + return "", 0, err } - payload, err := json.Marshal(data) + expiresAt := time.Now().Add(passkeyFlowTTL) + token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: purpose, + UserId: userID, + SessionId: sessionID, + Payload: string(payload), + ExpiresAt: expiresAt, + }) if err != nil { - return err + return "", 0, err } - session.Set(key, string(payload)) - return session.Save() + return token, expiresAt.Unix(), nil } -func PopSessionData(c *gin.Context, key string) (*webauthn.SessionData, error) { - session := sessions.Default(c) - raw := session.Get(key) - if raw == nil { - return nil, errSessionNotFound - } - session.Delete(key) - _ = session.Save() - var data webauthn.SessionData - switch value := raw.(type) { - case string: - if err := json.Unmarshal([]byte(value), &data); err != nil { - return nil, err - } - case []byte: - if err := json.Unmarshal(value, &data); err != nil { - return nil, err +func PopSessionDataFlow(token, purpose string, userID int, sessionID string) (*webauthn.SessionData, string, error) { + flow, err := model.ConsumeAuthFlow(token, model.AuthFlowMatch{ + Purpose: purpose, + UserId: userID, + SessionId: sessionID, + }) + if err != nil { + if errors.Is(err, model.ErrAuthFlowInvalid) || errors.Is(err, model.ErrAuthFlowExpired) || errors.Is(err, model.ErrAuthFlowConsumed) { + return nil, "", errSessionNotFound } - default: - return nil, errors.New("Passkey 会话格式无效") + return nil, "", err + } + var payload flowPayload + if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil { + return nil, "", err } - return &data, nil + return &payload.SessionData, payload.Scope, nil } diff --git a/web/default/src/components/sign-out-dialog.tsx b/web/default/src/components/sign-out-dialog.tsx index 537002d99e49..185d33bbbfb9 100644 --- a/web/default/src/components/sign-out-dialog.tsx +++ b/web/default/src/components/sign-out-dialog.tsx @@ -16,12 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ConfirmDialog } from '@/components/confirm-dialog' import { logout } from '@/features/auth/api' -import { useAuthStore } from '@/stores/auth-store' +import { clearAuthentication } from '@/lib/auth-session' interface SignOutDialogProps { open: boolean @@ -30,26 +31,28 @@ interface SignOutDialogProps { export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) { const { t } = useTranslation() - const { auth } = useAuthStore() + const [isSigningOut, setIsSigningOut] = useState(false) const handleSignOut = async () => { + setIsSigningOut(true) try { - await logout() - } catch { - /* empty */ - } - auth.reset() - try { + const response = await logout() + if (!response.success) { + toast.error(response.message || t('Failed to sign out session')) + return + } + + clearAuthentication() + toast.success(t('Signed out')) if (typeof window !== 'undefined') { - window.localStorage.removeItem('uid') + window.location.replace('/sign-in') } - } catch { - /* empty */ - } - toast.success(t('Signed out')) - // Refresh the page to clear all state and update UI - if (typeof window !== 'undefined') { - window.location.reload() + } catch (error: unknown) { + toast.error( + error instanceof Error ? error.message : t('Failed to sign out session') + ) + } finally { + setIsSigningOut(false) } } @@ -63,6 +66,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) { )} confirmText={t('Sign out')} handleConfirm={handleSignOut} + isLoading={isSigningOut} className='sm:max-w-sm' /> ) diff --git a/web/default/src/features/auth/api.test.ts b/web/default/src/features/auth/api.test.ts new file mode 100644 index 000000000000..455b68866c34 --- /dev/null +++ b/web/default/src/features/auth/api.test.ts @@ -0,0 +1,120 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import type { RefreshOutcome } from '@/lib/api' +import type { AuthBundle } from '@/stores/auth-store' + +import { executeLogout } from './api' + +const bundle: AuthBundle = { + access_token: 'access-token', + token_type: 'Bearer', + access_expires_at: 1_900_000_000, + user: { id: 1, username: 'test-user', role: 1 }, + session: { + sid: 'session-b', + current: true, + login_method: 'password', + ip: '127.0.0.1', + user_agent: 'test', + created_at: 1, + last_active_at: 1, + expires_at: 1_900_000_000, + }, +} + +function mismatchError() { + return { + isAxiosError: true, + response: { + status: 409, + data: { code: 'AUTH_SESSION_MISMATCH' }, + }, + } +} + +describe('logout coordination', () => { + test('returns an unsuccessful response without pretending to sign out', async () => { + let refreshCount = 0 + const result = await executeLogout({ + getExpectedSID: () => 'session-a', + request: async () => ({ success: false, message: 'not revoked' }), + refresh: async () => { + refreshCount += 1 + return { kind: 'anonymous' } + }, + }) + + assert.deepEqual(result, { success: false, message: 'not revoked' }) + assert.equal(refreshCount, 0) + }) + + test('recovers a cookie mismatch and retries with the refreshed SID', async () => { + let sid = 'session-a' + const requestedSIDs: Array = [] + const result = await executeLogout({ + getExpectedSID: () => sid, + request: async (expectedSID) => { + requestedSIDs.push(expectedSID) + if (requestedSIDs.length === 1) throw mismatchError() + return { success: true, message: '' } + }, + refresh: async () => { + sid = bundle.session.sid + return { kind: 'authenticated', bundle } + }, + }) + + assert.deepEqual(result, { success: true, message: '' }) + assert.deepEqual(requestedSIDs, ['session-a', 'session-b']) + }) + + test('treats a mismatch that refresh confirms anonymous as signed out', async () => { + const result = await executeLogout({ + getExpectedSID: () => 'session-a', + request: async () => { + throw mismatchError() + }, + refresh: async () => ({ kind: 'anonymous' }), + }) + + assert.deepEqual(result, { success: true, message: '' }) + }) + + test('preserves the active session when mismatch recovery is temporary', async () => { + const originalError = mismatchError() + const transient: RefreshOutcome = { + kind: 'transient_error', + error: new Error('offline'), + } + + await assert.rejects( + executeLogout({ + getExpectedSID: () => 'session-a', + request: async () => { + throw originalError + }, + refresh: async () => transient, + }), + (error) => error === originalError + ) + }) +}) diff --git a/web/default/src/features/auth/api.ts b/web/default/src/features/auth/api.ts index 24c45558d55a..2b8c81673908 100644 --- a/web/default/src/features/auth/api.ts +++ b/web/default/src/features/auth/api.ts @@ -16,8 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { api } from '@/lib/api' +import axios from 'axios' +import { api, refreshAuthentication, type RefreshOutcome } from '@/lib/api' +import { useAuthStore } from '@/stores/auth-store' + +import { getAffiliateCode } from './lib/storage' import type { LoginPayload, LoginResponse, @@ -43,21 +47,68 @@ export async function login(payload: LoginPayload) { { username: payload.username, password: payload.password, - } + }, + { skipAuthRefresh: true } ) return res.data } // Two-factor authentication login export async function login2fa(payload: TwoFAPayload) { - const res = await api.post('/api/user/login/2fa', payload) + const res = await api.post('/api/user/login/2fa', payload, { + skipAuthRefresh: true, + }) return res.data } +interface LogoutRuntime { + getExpectedSID: () => string | undefined + request: (expectedSID?: string) => Promise + refresh: () => Promise +} + +export async function executeLogout( + runtime: LogoutRuntime, + allowMismatchRecovery = true +): Promise { + try { + return await runtime.request(runtime.getExpectedSID()) + } catch (error: unknown) { + const code = axios.isAxiosError(error) + ? error.response?.data?.code + : undefined + if ( + allowMismatchRecovery && + axios.isAxiosError(error) && + error.response?.status === 409 && + code === 'AUTH_SESSION_MISMATCH' + ) { + const outcome = await runtime.refresh() + if (outcome.kind === 'authenticated') { + return executeLogout(runtime, false) + } + if (outcome.kind === 'anonymous') { + return { success: true, message: '' } + } + } + throw error + } +} + // User logout export async function logout(): Promise { - const res = await api.get('/api/user/logout') - return res.data + return executeLogout({ + getExpectedSID: () => useAuthStore.getState().auth.session?.sid, + request: async (sid) => { + const res = await api.post('/api/user/auth/logout', undefined, { + headers: sid ? { 'X-Auth-Session': sid } : undefined, + skipAuthRefresh: true, + skipErrorHandler: true, + }) + return res.data + }, + refresh: refreshAuthentication, + }) } // ---------------------------------------------------------------------------- @@ -86,12 +137,23 @@ export async function githubOAuthStart(clientId: string, state: string) { } // Get OAuth state for CSRF protection -export async function getOAuthState(): Promise { - const aff = - typeof window !== 'undefined' ? (localStorage.getItem('aff') ?? '') : '' - const res = await api.get('/api/oauth/state', { params: { aff } }) - if (res.data?.success) return res.data.data - return '' +export async function createOAuthFlow( + provider: string, + intent: 'login' | 'bind' +): Promise { + const aff = intent === 'login' ? getAffiliateCode() : '' + const res = await api.post( + '/api/oauth/state', + { provider, intent, aff: aff || undefined }, + { skipAuthRefresh: intent === 'login' } + ) + if (res.data?.success) { + if (typeof res.data.data === 'string') return res.data.data + if (typeof res.data.data?.flow_token === 'string') { + return res.data.data.flow_token + } + } + throw new Error(res.data?.message || 'Failed to initialize OAuth') } // WeChat login by authorization code diff --git a/web/default/src/features/auth/constants.ts b/web/default/src/features/auth/constants.ts index 8769ddead43c..687a81efb789 100644 --- a/web/default/src/features/auth/constants.ts +++ b/web/default/src/features/auth/constants.ts @@ -75,4 +75,6 @@ export const PASSWORD_RESET_COUNTDOWN = 30 // seconds // OAuth Constants // ============================================================================ -export const OAUTH_BIND_STORAGE_KEY = 'oauth:binding:result' +export const OAUTH_BIND_CALLBACK_MESSAGE = 'oauth:binding:callback' +export const OAUTH_BIND_RESULT_MESSAGE = 'oauth:binding:result' +export const TELEGRAM_BIND_RESULT_MESSAGE = 'telegram:binding:result' diff --git a/web/default/src/features/auth/hooks/use-auth-redirect.ts b/web/default/src/features/auth/hooks/use-auth-redirect.ts index 1da607161a8d..940eedf3823d 100644 --- a/web/default/src/features/auth/hooks/use-auth-redirect.ts +++ b/web/default/src/features/auth/hooks/use-auth-redirect.ts @@ -19,24 +19,20 @@ For commercial licensing, please contact support@quantumnous.com import { useNavigate } from '@tanstack/react-router' import i18n from 'i18next' -import type { User } from '@/features/users/types' -import { getSelf } from '@/lib/api' -import { useAuthStore } from '@/stores/auth-store' +import { applyAuthBundle } from '@/lib/api' +import type { AuthBundle, AuthUser } from '@/stores/auth-store' -import { saveUserId } from '../lib/storage' - -function getSavedLanguage(user: User): string | undefined { - const userData = user as Record - if (typeof userData.language === 'string') { - return userData.language +function getSavedLanguage(user: AuthUser): string | undefined { + if (typeof user.language === 'string') { + return user.language } - if (typeof userData.setting !== 'string') { + if (typeof user.setting !== 'string') { return undefined } try { - const setting = JSON.parse(userData.setting) as { language?: unknown } + const setting = JSON.parse(user.setting) as { language?: unknown } return typeof setting.language === 'string' ? setting.language : undefined } catch { return undefined @@ -48,7 +44,6 @@ function getSavedLanguage(user: User): string | undefined { */ export function useAuthRedirect() { const navigate = useNavigate() - const { auth } = useAuthStore() /** * Handle successful login @@ -56,35 +51,13 @@ export function useAuthRedirect() { * @param redirectTo - Redirect path after login */ const handleLoginSuccess = async ( - userData?: { id?: number } | null, + bundle: AuthBundle, redirectTo?: string ) => { - // Save user ID if available - if (userData?.id) { - saveUserId(userData.id) - } - - // Fetch and set user data - try { - const self = await getSelf() - if (self?.success && self.data) { - const user = self.data as User - auth.setUser(user) - - // Update user ID if not already set - if (user.id) { - saveUserId(user.id) - } - - // Restore saved language preference - const savedLang = getSavedLanguage(user) - if (savedLang && savedLang !== i18n.language) { - i18n.changeLanguage(savedLang) - } - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to fetch user data:', error) + applyAuthBundle(bundle) + const savedLang = getSavedLanguage(bundle.user) + if (savedLang && savedLang !== i18n.language) { + await i18n.changeLanguage(savedLang) } // Navigate to target page diff --git a/web/default/src/features/auth/hooks/use-oauth-login.ts b/web/default/src/features/auth/hooks/use-oauth-login.ts index ff4f4e6983c1..32b9f8b4ea82 100644 --- a/web/default/src/features/auth/hooks/use-oauth-login.ts +++ b/web/default/src/features/auth/hooks/use-oauth-login.ts @@ -16,15 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import type { AxiosRequestConfig } from 'axios' import { useState, useRef, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { api } from '@/lib/api' -import { useAuthStore } from '@/stores/auth-store' +import { clearAuthentication } from '@/lib/api' -import { getOAuthState } from '../api' +import { createOAuthFlow, logout } from '../api' import { buildGitHubOAuthUrl, buildDiscordOAuthUrl, @@ -33,10 +31,6 @@ import { } from '../lib/oauth' import type { SystemStatus, CustomOAuthProviderInfo } from '../types' -type LogoutRequestConfig = AxiosRequestConfig & { - skipErrorHandler?: boolean -} - /** * Hook for managing OAuth login */ @@ -46,7 +40,6 @@ export function useOAuthLogin(status: SystemStatus | null) { const [githubButtonText, setGithubButtonText] = useState('') const [githubButtonDisabled, setGithubButtonDisabled] = useState(false) const githubTimeoutRef = useRef(null) - const { auth } = useAuthStore() useEffect(() => { setGithubButtonText(t('Continue with GitHub')) @@ -59,18 +52,11 @@ export function useOAuthLogin(status: SystemStatus | null) { }, [t]) const resetSession = async () => { - try { - auth.reset() - } catch (_error) { - // ignore store reset errors - } - try { - await api.get('/api/user/logout', { - skipErrorHandler: true, - } as LogoutRequestConfig) - } catch (_error) { - // ignore logout errors + const response = await logout() + if (!response.success) { + throw new Error(response.message || t('Failed to sign out session')) } + clearAuthentication() } const handleGitHubLogin = async () => { @@ -95,21 +81,11 @@ export function useOAuthLogin(status: SystemStatus | null) { try { await resetSession() - const state = await getOAuthState() - if (!state) { - toast.error(t('Failed to initialize OAuth')) - if (githubTimeoutRef.current) { - clearTimeout(githubTimeoutRef.current) - } - setIsLoading(false) - setGithubButtonText(t('Continue with GitHub')) - setGithubButtonDisabled(false) - return - } + const state = await createOAuthFlow('github', 'login') const url = buildGitHubOAuthUrl(status.github_client_id, state) window.open(url, '_self') - } catch (_error) { + } catch { toast.error(t('Failed to start GitHub login')) if (githubTimeoutRef.current) { clearTimeout(githubTimeoutRef.current) @@ -126,15 +102,11 @@ export function useOAuthLogin(status: SystemStatus | null) { setIsLoading(true) try { await resetSession() - const state = await getOAuthState() - if (!state) { - toast.error(t('Failed to initialize OAuth')) - return - } + const state = await createOAuthFlow('discord', 'login') const url = buildDiscordOAuthUrl(status.discord_client_id, state) window.open(url, '_self') - } catch (_error) { + } catch { toast.error(t('Failed to start Discord login')) } finally { setIsLoading(false) @@ -147,11 +119,7 @@ export function useOAuthLogin(status: SystemStatus | null) { setIsLoading(true) try { await resetSession() - const state = await getOAuthState() - if (!state) { - toast.error(t('Failed to initialize OAuth')) - return - } + const state = await createOAuthFlow('oidc', 'login') const url = buildOIDCOAuthUrl( status.oidc_authorization_endpoint, @@ -159,7 +127,7 @@ export function useOAuthLogin(status: SystemStatus | null) { state ) window.open(url, '_self') - } catch (_error) { + } catch { toast.error(t('Failed to start OIDC login')) } finally { setIsLoading(false) @@ -172,15 +140,11 @@ export function useOAuthLogin(status: SystemStatus | null) { setIsLoading(true) try { await resetSession() - const state = await getOAuthState() - if (!state) { - toast.error(t('Failed to initialize OAuth')) - return - } + const state = await createOAuthFlow('linuxdo', 'login') const url = buildLinuxDOOAuthUrl(status.linuxdo_client_id, state) window.open(url, '_self') - } catch (_error) { + } catch { toast.error(t('Failed to start LinuxDO login')) } finally { setIsLoading(false) @@ -197,11 +161,7 @@ export function useOAuthLogin(status: SystemStatus | null) { setIsLoading(true) try { await resetSession() - const state = await getOAuthState() - if (!state) { - toast.error(t('Failed to initialize OAuth')) - return - } + const state = await createOAuthFlow(provider.slug, 'login') const redirectUri = `${window.location.origin}/oauth/${provider.slug}` const url = new URL(provider.authorization_endpoint) @@ -214,7 +174,7 @@ export function useOAuthLogin(status: SystemStatus | null) { } window.open(url.toString(), '_self') - } catch (_error) { + } catch { toast.error( t('Failed to start {{provider}} login', { provider: provider.name }) ) diff --git a/web/default/src/features/auth/index.ts b/web/default/src/features/auth/index.ts index 1ba4e4ac2187..2286a1ca2f04 100644 --- a/web/default/src/features/auth/index.ts +++ b/web/default/src/features/auth/index.ts @@ -28,7 +28,7 @@ export { sendPasswordResetEmail, sendEmailVerification, bindEmail, - getOAuthState, + createOAuthFlow, githubOAuthStart, wechatLoginByCode, } from './api' @@ -84,13 +84,7 @@ export { hasOAuthProviders, } from './lib/oauth' -export { - saveUserId, - getUserId, - removeUserId, - getAffiliateCode, - saveAffiliateCode, -} from './lib/storage' +export { getAffiliateCode, saveAffiliateCode } from './lib/storage' export { isValidOTP, diff --git a/web/default/src/features/auth/lib/oauth-bind-window.test.ts b/web/default/src/features/auth/lib/oauth-bind-window.test.ts new file mode 100644 index 000000000000..b63ee3a45d18 --- /dev/null +++ b/web/default/src/features/auth/lib/oauth-bind-window.test.ts @@ -0,0 +1,91 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { + startOAuthBindResponseDeadline, + watchOAuthPopupClosed, +} from './oauth-bind-window' + +function fakeTimerRuntime() { + let callback: (() => void) | undefined + let delay = 0 + const cancelled: unknown[] = [] + const handle = Symbol('timer') + return { + runtime: { + schedule: (scheduled: () => void, scheduledDelay: number) => { + callback = scheduled + delay = scheduledDelay + return handle + }, + cancel: (cancelledHandle: unknown) => cancelled.push(cancelledHandle), + }, + fire: () => callback?.(), + get delay() { + return delay + }, + cancelled, + handle, + } +} + +describe('OAuth bind popup lifecycle', () => { + test('waits 30 seconds for the opener response and can be cancelled', () => { + const timer = fakeTimerRuntime() + let timedOut = false + const cancel = startOAuthBindResponseDeadline( + () => { + timedOut = true + }, + undefined, + timer.runtime + ) + + assert.equal(timer.delay, 30_000) + cancel() + timer.fire() + assert.equal(timedOut, false) + assert.deepEqual(timer.cancelled, [timer.handle]) + }) + + test('reports a closed popup once and clears its poller', () => { + const timer = fakeTimerRuntime() + const popup = { closed: false } + let closedCount = 0 + watchOAuthPopupClosed( + popup, + () => { + closedCount += 1 + }, + undefined, + timer.runtime + ) + + assert.equal(timer.delay, 500) + timer.fire() + assert.equal(closedCount, 0) + popup.closed = true + timer.fire() + timer.fire() + assert.equal(closedCount, 1) + assert.deepEqual(timer.cancelled, [timer.handle]) + }) +}) diff --git a/web/default/src/features/auth/lib/oauth-bind-window.ts b/web/default/src/features/auth/lib/oauth-bind-window.ts new file mode 100644 index 000000000000..605adf3d2e1e --- /dev/null +++ b/web/default/src/features/auth/lib/oauth-bind-window.ts @@ -0,0 +1,74 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +interface TimerRuntime { + schedule: (callback: () => void, delay: number) => unknown + cancel: (handle: unknown) => void +} + +const timeoutRuntime: TimerRuntime = { + schedule: (callback, delay) => globalThis.setTimeout(callback, delay), + cancel: (handle) => + globalThis.clearTimeout(handle as ReturnType), +} + +const intervalRuntime: TimerRuntime = { + schedule: (callback, delay) => globalThis.setInterval(callback, delay), + cancel: (handle) => + globalThis.clearInterval( + handle as ReturnType + ), +} + +export function startOAuthBindResponseDeadline( + onTimeout: () => void, + delay = 30_000, + runtime: TimerRuntime = timeoutRuntime +): () => void { + let active = true + const handle = runtime.schedule(() => { + if (!active) return + active = false + onTimeout() + }, delay) + return () => { + if (!active) return + active = false + runtime.cancel(handle) + } +} + +export function watchOAuthPopupClosed( + popup: Pick, + onClosed: () => void, + interval = 500, + runtime: TimerRuntime = intervalRuntime +): () => void { + let active = true + const handle = runtime.schedule(() => { + if (!active || !popup.closed) return + active = false + runtime.cancel(handle) + onClosed() + }, interval) + return () => { + if (!active) return + active = false + runtime.cancel(handle) + } +} diff --git a/web/default/src/features/auth/lib/storage.ts b/web/default/src/features/auth/lib/storage.ts index 6660b075fe93..529c7bd66457 100644 --- a/web/default/src/features/auth/lib/storage.ts +++ b/web/default/src/features/auth/lib/storage.ts @@ -25,55 +25,10 @@ For commercial licensing, please contact support@quantumnous.com // ============================================================================ const STORAGE_KEYS = { - USER_ID: 'uid', AFFILIATE: 'aff', STATUS: 'status', } as const -// ============================================================================ -// User ID Storage -// ============================================================================ - -/** - * Save user ID to localStorage - */ -export function saveUserId(userId: number | string): void { - if (typeof window === 'undefined') return - try { - window.localStorage.setItem(STORAGE_KEYS.USER_ID, String(userId)) - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to save user ID:', error) - } -} - -/** - * Get user ID from localStorage - */ -export function getUserId(): string | null { - if (typeof window === 'undefined') return null - try { - return window.localStorage.getItem(STORAGE_KEYS.USER_ID) - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to get user ID:', error) - return null - } -} - -/** - * Remove user ID from localStorage - */ -export function removeUserId(): void { - if (typeof window === 'undefined') return - try { - window.localStorage.removeItem(STORAGE_KEYS.USER_ID) - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to remove user ID:', error) - } -} - // ============================================================================ // Affiliate Code Storage // ============================================================================ diff --git a/web/default/src/features/auth/otp/components/otp-form.tsx b/web/default/src/features/auth/otp/components/otp-form.tsx index cf5c1fcbaac7..dc2494dd2e21 100644 --- a/web/default/src/features/auth/otp/components/otp-form.tsx +++ b/web/default/src/features/auth/otp/components/otp-form.tsx @@ -48,14 +48,12 @@ import { BACKUP_CODE_LENGTH, } from '@/features/auth/constants' import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect' -import { saveUserId } from '@/features/auth/lib/storage' import { isValidOTP, isValidBackupCode, formatBackupCode, cleanBackupCode, } from '@/features/auth/lib/validation' -import type { User } from '@/features/users/types' import { cn } from '@/lib/utils' import { useAuthStore } from '@/stores/auth-store' @@ -66,8 +64,10 @@ export function OtpForm({ className, ...props }: OtpFormProps) { const [isLoading, setIsLoading] = useState(false) const [useBackupCode, setUseBackupCode] = useState(false) - const { auth } = useAuthStore() - const { redirectToLogin } = useAuthRedirect() + const pending2FAFlowToken = useAuthStore( + (state) => state.auth.pending2FAFlowToken + ) + const { handleLoginSuccess, redirectToLogin } = useAuthRedirect() const form = useForm>({ resolver: zodResolver(otpFormSchema), @@ -94,29 +94,27 @@ export function OtpForm({ className, ...props }: OtpFormProps) { try { // Remove all hyphens from backup code before sending to backend const code = useBackupCode ? cleanBackupCode(data.otp) : data.otp - const res = await login2fa({ code }) + if (!pending2FAFlowToken) { + toast.error(t('Login flow expired. Please sign in again.')) + redirectToLogin() + return + } + const res = await login2fa({ + code, + flow_token: pending2FAFlowToken, + }) if (!res.success) { toast.error(res.message || t('Invalid code')) return } - // Handle user data from 2FA login response - const userData = res.data - if (!userData) { - throw new Error('No user data received from login') - } - - // Update auth store - auth.setUser(userData as User) - - // Store user ID in localStorage for compatibility - if (userData.id) { - saveUserId(userData.id) + if (!res.data) { + throw new Error(t('Login failed')) } + await handleLoginSuccess(res.data) toast.success(t('Signed in')) - redirectToLogin() // This will redirect to dashboard via the redirect logic } catch (error) { // eslint-disable-next-line no-console console.error('2FA verification error:', error) diff --git a/web/default/src/features/auth/passkey/api.ts b/web/default/src/features/auth/passkey/api.ts index 077fa662f59a..b7145316314e 100644 --- a/web/default/src/features/auth/passkey/api.ts +++ b/web/default/src/features/auth/passkey/api.ts @@ -18,34 +18,53 @@ For commercial licensing, please contact support@quantumnous.com */ import { api } from '@/lib/api' +import type { + SecurityProof, + SecurityProofScope, +} from '../secure-verification/types' import type { ApiResponse, PasskeyOptionsPayload, PasskeyStatus } from './types' +function proofHeaders(proofToken?: string): Record | undefined { + return proofToken ? { 'X-Security-Proof': proofToken } : undefined +} + export async function getPasskeyStatus(): Promise> { const res = await api.get>('/api/user/passkey') return res.data } -export async function beginPasskeyRegistration(): Promise< - ApiResponse -> { +export async function beginPasskeyRegistration( + proofToken?: string +): Promise> { const res = await api.post>( - '/api/user/passkey/register/begin' + '/api/user/passkey/register/begin', + undefined, + { headers: proofHeaders(proofToken) } ) return res.data } export async function finishPasskeyRegistration( - payload: Record + flowToken: string, + payload: Record, + proofToken?: string ): Promise { const res = await api.post( '/api/user/passkey/register/finish', - payload + { + flow_token: flowToken, + credential: payload, + }, + { headers: proofHeaders(proofToken), acceptAuthRotation: true } ) return res.data } -export async function deletePasskey(): Promise { - const res = await api.delete('/api/user/passkey') +export async function deletePasskey(proofToken?: string): Promise { + const res = await api.delete('/api/user/passkey', { + headers: proofHeaders(proofToken), + acceptAuthRotation: true, + }) return res.data } @@ -59,30 +78,34 @@ export async function beginPasskeyLogin(): Promise< } export async function finishPasskeyLogin( + flowToken: string, payload: Record ): Promise { const res = await api.post( '/api/user/passkey/login/finish', - payload + { flow_token: flowToken, credential: payload }, + { skipAuthRefresh: true } ) return res.data } -export async function beginPasskeyVerification(): Promise< - ApiResponse -> { +export async function beginPasskeyVerification( + scope: SecurityProofScope +): Promise> { const res = await api.post>( - '/api/user/passkey/verify/begin' + '/api/user/passkey/verify/begin', + { scope } ) return res.data } export async function finishPasskeyVerification( + flowToken: string, payload: Record -): Promise { - const res = await api.post( +): Promise> { + const res = await api.post>( '/api/user/passkey/verify/finish', - payload + { flow_token: flowToken, credential: payload } ) return res.data } diff --git a/web/default/src/features/auth/passkey/hooks/use-passkey-management.ts b/web/default/src/features/auth/passkey/hooks/use-passkey-management.ts index 670c9d664fcb..1c74d4fc0312 100644 --- a/web/default/src/features/auth/passkey/hooks/use-passkey-management.ts +++ b/web/default/src/features/auth/passkey/hooks/use-passkey-management.ts @@ -81,95 +81,110 @@ export function usePasskeyManagement( .catch(() => setSupported(false)) }, []) - const register = useCallback(async () => { - if (!supported) { - toast.error(i18next.t('This device does not support Passkey')) - return false - } - if (!navigator?.credentials) { - toast.error(i18next.t('Passkey is not supported in this environment')) - return false - } - - setRegistering(true) - try { - const beginResponse = await beginPasskeyRegistration() - if (!beginResponse.success) { - toast.error( - beginResponse.message || - i18next.t('Failed to start Passkey registration') - ) - return false - } - - const publicKey = prepareCredentialCreationOptions( - beginResponse.data?.options ?? beginResponse.data - ) - - const credential = (await createCredential( - publicKey - )) as PublicKeyCredential | null - if (!credential) { - toast.error(i18next.t('Passkey registration was cancelled')) + const register = useCallback( + async (proofToken?: string) => { + if (!supported) { + toast.error(i18next.t('This device does not support Passkey')) return false } - - const attestation = buildRegistrationResult(credential) - if (!attestation) { - toast.error(i18next.t('Invalid Passkey registration response')) + if (!navigator?.credentials) { + toast.error(i18next.t('Passkey is not supported in this environment')) return false } - const finishResponse = await finishPasskeyRegistration(attestation) - if (!finishResponse.success) { + setRegistering(true) + try { + const beginResponse = await beginPasskeyRegistration(proofToken) + if (!beginResponse.success) { + toast.error( + beginResponse.message || + i18next.t('Failed to start Passkey registration') + ) + return false + } + + const publicKey = prepareCredentialCreationOptions( + beginResponse.data?.options ?? beginResponse.data + ) + const flowToken = beginResponse.data?.flow_token + if (!flowToken) { + toast.error(i18next.t('Registration flow expired. Please try again.')) + return false + } + + const credential = (await createCredential( + publicKey + )) as PublicKeyCredential | null + if (!credential) { + toast.error(i18next.t('Passkey registration was cancelled')) + return false + } + + const attestation = buildRegistrationResult(credential) + if (!attestation) { + toast.error(i18next.t('Invalid Passkey registration response')) + return false + } + + const finishResponse = await finishPasskeyRegistration( + flowToken, + attestation, + proofToken + ) + if (!finishResponse.success) { + toast.error( + finishResponse.message || i18next.t('Failed to register Passkey') + ) + return false + } + + toast.success(i18next.t('Passkey registered successfully')) + await fetchStatus() + return true + } catch (error: unknown) { + if (error instanceof DOMException && error.name === 'NotAllowedError') { + toast.info(i18next.t('Passkey registration was cancelled')) + return false + } + // eslint-disable-next-line no-console + console.error('[Passkey] Registration error', error) toast.error( - finishResponse.message || i18next.t('Failed to register Passkey') + error instanceof Error + ? error.message + : i18next.t('Failed to register Passkey') ) return false + } finally { + setRegistering(false) } - - toast.success(i18next.t('Passkey registered successfully')) - await fetchStatus() - return true - } catch (error: unknown) { - if (error instanceof DOMException && error.name === 'NotAllowedError') { - toast.info(i18next.t('Passkey registration was cancelled')) + }, + [supported, fetchStatus] + ) + + const remove = useCallback( + async (proofToken?: string) => { + setRemoving(true) + try { + const res = await deletePasskey(proofToken) + if (!res.success) { + toast.error(res.message || i18next.t('Failed to remove Passkey')) + return false + } + + toast.success(i18next.t('Passkey removed successfully')) + await fetchStatus() + return true + } catch (error) { + // eslint-disable-next-line no-console + console.error('[Passkey] Removal error', error) + toast.error(i18next.t('Failed to remove Passkey')) return false + } finally { + setRemoving(false) } - // eslint-disable-next-line no-console - console.error('[Passkey] Registration error', error) - toast.error( - error instanceof Error - ? error.message - : i18next.t('Failed to register Passkey') - ) - return false - } finally { - setRegistering(false) - } - }, [supported, fetchStatus]) - - const remove = useCallback(async () => { - setRemoving(true) - try { - const res = await deletePasskey() - if (!res.success) { - toast.error(res.message || i18next.t('Failed to remove Passkey')) - return false - } - - toast.success(i18next.t('Passkey removed successfully')) - await fetchStatus() - return true - } catch (error) { - // eslint-disable-next-line no-console - console.error('[Passkey] Removal error', error) - toast.error(i18next.t('Failed to remove Passkey')) - return false - } finally { - setRemoving(false) - } - }, [fetchStatus]) + }, + [fetchStatus] + ) const enabled = useMemo(() => Boolean(status?.enabled), [status]) const lastUsed = useMemo(() => status?.last_used_at ?? null, [status]) diff --git a/web/default/src/features/auth/passkey/types.ts b/web/default/src/features/auth/passkey/types.ts index a331a549c146..b65bc338b425 100644 --- a/web/default/src/features/auth/passkey/types.ts +++ b/web/default/src/features/auth/passkey/types.ts @@ -32,6 +32,8 @@ export interface PasskeyStatus { export interface PasskeyOptionsPayload { options?: unknown + flow_token?: string + expires_at?: number publicKey?: unknown response?: unknown Response?: unknown diff --git a/web/default/src/features/auth/secure-verification/api.ts b/web/default/src/features/auth/secure-verification/api.ts index d5cada898061..6b972027b2a0 100644 --- a/web/default/src/features/auth/secure-verification/api.ts +++ b/web/default/src/features/auth/secure-verification/api.ts @@ -16,6 +16,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import i18next from 'i18next' + import { api, get2FAStatus } from '@/lib/api' import { buildAssertionResult, @@ -28,7 +30,12 @@ import { finishPasskeyVerification, getPasskeyStatus, } from '../passkey' -import type { VerificationMethod, VerificationMethods } from './types' +import type { + SecurityProof, + SecurityProofScope, + VerificationMethod, + VerificationMethods, +} from './types' /** * Fetch available verification methods for the current user. @@ -69,91 +76,112 @@ export async function checkVerificationMethods(): Promise { */ export async function verify( method: VerificationMethod, + scope: SecurityProofScope, code?: string -): Promise { +): Promise { switch (method) { case '2fa': - return verifyTwoFA(code) + return verifyTwoFA(scope, code) case 'passkey': - return verifyPasskey() + return verifyPasskey(scope) default: - throw new Error(`Unsupported verification method: ${method}`) + throw new Error( + i18next.t('Unsupported verification method: {{method}}', { method }) + ) } } /** * Perform 2FA verification flow. */ -async function verifyTwoFA(code?: string | null): Promise { +async function verifyTwoFA( + scope: SecurityProofScope, + code?: string | null +): Promise { const trimmed = code?.trim() if (!trimmed) { - throw new Error('Please enter the verification code or backup code') + throw new Error( + i18next.t('Please enter the verification code or backup code') + ) } const res = await api.post('/api/verify', { method: '2fa', code: trimmed, + scope, }) if (!res.data?.success) { - throw new Error(res.data?.message || 'Verification failed') + throw new Error(res.data?.message || i18next.t('Verification failed')) + } + if (!res.data.data?.proof_token) { + throw new Error(i18next.t('Verification proof was not returned')) } + return res.data.data as SecurityProof } /** * Perform Passkey verification flow. */ -async function verifyPasskey(): Promise { +async function verifyPasskey( + scope: SecurityProofScope +): Promise { if (typeof navigator === 'undefined' || !navigator.credentials) { - throw new Error('Passkey verification is not supported in this environment') + throw new Error( + i18next.t('Passkey verification is not supported in this environment') + ) } try { - const beginResponse = await beginPasskeyVerification() + const beginResponse = await beginPasskeyVerification(scope) if (!beginResponse.success) { - throw new Error(beginResponse.message || 'Failed to start verification') + throw new Error( + beginResponse.message || i18next.t('Failed to start verification') + ) } const publicKey = prepareCredentialRequestOptions( beginResponse.data?.options ?? beginResponse.data ) + const flowToken = beginResponse.data?.flow_token + if (!flowToken) { + throw new Error(i18next.t('Verification flow expired')) + } const credential = (await navigator.credentials.get({ publicKey, })) as PublicKeyCredential | null if (!credential) { - throw new Error('Passkey verification was cancelled') + throw new Error(i18next.t('Passkey verification was cancelled')) } const assertion = buildAssertionResult(credential) if (!assertion) { - throw new Error('Unable to build Passkey assertion') + throw new Error(i18next.t('Unable to build Passkey assertion')) } - const finishResponse = await finishPasskeyVerification(assertion) + const finishResponse = await finishPasskeyVerification(flowToken, assertion) if (!finishResponse.success) { - throw new Error(finishResponse.message || 'Passkey verification failed') - } - - const verifyResponse = await api.post('/api/verify', { - method: 'passkey', - }) - - if (!verifyResponse.data?.success) { throw new Error( - verifyResponse.data?.message || 'Failed to complete verification' + finishResponse.message || i18next.t('Passkey verification failed') ) } + + if (!finishResponse.data?.proof_token) { + throw new Error(i18next.t('Verification proof was not returned')) + } + return finishResponse.data } catch (error: unknown) { if (error instanceof DOMException && error.name === 'NotAllowedError') { - throw new Error('Passkey verification was cancelled or timed out', { - cause: error, - }) + throw new Error( + i18next.t('Passkey verification was cancelled or timed out'), + { cause: error } + ) } if (error instanceof DOMException && error.name === 'InvalidStateError') { throw new Error( - 'Passkey verification is not available in the current state', + i18next.t('Passkey verification is not available in the current state'), { cause: error } ) } diff --git a/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts b/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts index a9d95cccc3fe..ed244483b8a1 100644 --- a/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts +++ b/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts @@ -34,7 +34,7 @@ import type { VerificationMethods, } from '../types' -type ApiCall = (() => Promise) | null +type ApiCall = ((proofToken?: string) => Promise) | null interface InternalState extends SecureVerificationState { apiCall: ApiCall @@ -81,10 +81,10 @@ export function useSecureVerification( const startVerification = useCallback( async ( - apiCall: () => Promise, - config: StartVerificationOptions = {} + apiCall: (proofToken?: string) => Promise, + config: StartVerificationOptions ) => { - const { preferredMethod, title, description } = config + const { preferredMethod, scope, title, description } = config const availableMethods = await fetchVerificationMethods() if (!availableMethods.has2FA && !availableMethods.hasPasskey) { @@ -102,6 +102,14 @@ export function useSecureVerification( } let defaultMethod: VerificationMethod | null = preferredMethod ?? null + if ( + (defaultMethod === 'passkey' && + (!availableMethods.hasPasskey || + !availableMethods.passkeySupported)) || + (defaultMethod === '2fa' && !availableMethods.has2FA) + ) { + defaultMethod = null + } if (!defaultMethod) { if (availableMethods.hasPasskey && availableMethods.passkeySupported) { defaultMethod = 'passkey' @@ -114,6 +122,7 @@ export function useSecureVerification( ...prev, apiCall, method: defaultMethod, + scope, title, description, })) @@ -139,8 +148,15 @@ export function useSecureVerification( setState((prev) => ({ ...prev, loading: true })) try { - await verify(actualMethod, code ?? state.code) - const result = await state.apiCall() + if (!state.scope) { + throw new Error('Verification scope is missing') + } + const proof = await verify( + actualMethod, + state.scope, + code ?? state.code + ) + const result = await state.apiCall(proof.proof_token) if (successMessage) { toast.success(successMessage) @@ -182,8 +198,8 @@ export function useSecureVerification( const withVerification = useCallback( async ( - apiCall: () => Promise, - config: StartVerificationOptions = {} + apiCall: (proofToken?: string) => Promise, + config: StartVerificationOptions ) => { try { return await apiCall() diff --git a/web/default/src/features/auth/secure-verification/types.ts b/web/default/src/features/auth/secure-verification/types.ts index dfb99fe3f803..9d368225b974 100644 --- a/web/default/src/features/auth/secure-verification/types.ts +++ b/web/default/src/features/auth/secure-verification/types.ts @@ -18,6 +18,18 @@ For commercial licensing, please contact support@quantumnous.com */ export type VerificationMethod = '2fa' | 'passkey' +export type SecurityProofScope = + | 'channel.key.read' + | 'passkey.register' + | 'passkey.delete' + +export interface SecurityProof { + proof_token: string + expires_at: number + method: VerificationMethod + scope: SecurityProofScope +} + export interface VerificationMethods { has2FA: boolean hasPasskey: boolean @@ -26,6 +38,7 @@ export interface VerificationMethods { export interface SecureVerificationState { method: VerificationMethod | null + scope?: SecurityProofScope loading: boolean code: string title?: string @@ -40,6 +53,7 @@ export interface UseSecureVerificationOptions { } export interface StartVerificationOptions { + scope: SecurityProofScope preferredMethod?: VerificationMethod title?: string description?: string diff --git a/web/default/src/features/auth/sign-in/components/user-auth-form.tsx b/web/default/src/features/auth/sign-in/components/user-auth-form.tsx index 913c98862055..2782b5cf53e0 100644 --- a/web/default/src/features/auth/sign-in/components/user-auth-form.tsx +++ b/web/default/src/features/auth/sign-in/components/user-auth-form.tsx @@ -48,12 +48,14 @@ import { useTurnstile } from '@/features/auth/hooks/use-turnstile' import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey' import type { AuthFormProps } from '@/features/auth/types' import { useStatus } from '@/hooks/use-status' +import { isAuthBundle } from '@/lib/api' import { buildAssertionResult, prepareCredentialRequestOptions, isPasskeySupported as detectPasskeySupport, } from '@/lib/passkey' import { cn } from '@/lib/utils' +import { useAuthStore } from '@/stores/auth-store' export function UserAuthForm({ className, @@ -87,6 +89,9 @@ export function UserAuthForm({ validateTurnstile, } = useTurnstile() const { handleLoginSuccess, redirectTo2FA } = useAuthRedirect() + const setPending2FAFlowToken = useAuthStore( + (state) => state.auth.setPending2FAFlowToken + ) const hasUserAgreement = Boolean(status?.user_agreement_enabled) const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled) @@ -160,15 +165,22 @@ export function UserAuthForm({ }) if (res.success) { - if (res.data?.require_2fa) { + if (res.data && 'require_2fa' in res.data && res.data.require_2fa) { + if (!res.data.flow_token) { + throw new Error(t('Login flow expired. Please sign in again.')) + } + setPending2FAFlowToken(res.data.flow_token) redirectTo2FA() return } - await handleLoginSuccess(res.data as { id?: number } | null, redirectTo) + if (!isAuthBundle(res.data)) { + throw new Error(t('Login failed')) + } + await handleLoginSuccess(res.data, redirectTo) toast.success(t('Welcome back!')) } - } catch (_error) { + } catch { // Errors are handled by global interceptor } finally { setIsLoading(false) @@ -201,14 +213,14 @@ export function UserAuthForm({ setIsWeChatSubmitting(true) try { const res = await wechatLoginByCode(wechatCode) - if (res?.success) { - await handleLoginSuccess(res.data as { id?: number } | null, redirectTo) + if (res?.success && isAuthBundle(res.data)) { + await handleLoginSuccess(res.data, redirectTo) toast.success(t('Signed in via WeChat')) handleWeChatDialogChange(false) } else { toast.error(res?.message || loginFailedMessage) } - } catch (_error) { + } catch { toast.error(loginFailedMessage) } finally { setIsWeChatSubmitting(false) @@ -241,6 +253,10 @@ export function UserAuthForm({ const publicKey = prepareCredentialRequestOptions( begin.data?.options ?? begin.data ) + const flowToken = begin.data?.flow_token + if (!flowToken) { + throw new Error(t('Login flow expired. Please sign in again.')) + } const credential = (await navigator.credentials.get({ publicKey, @@ -256,19 +272,16 @@ export function UserAuthForm({ throw new Error(t('Invalid Passkey response')) } - const finish = await finishPasskeyLogin(assertion) + const finish = await finishPasskeyLogin(flowToken, assertion) if (!finish.success) { throw new Error(finish.message || t('Failed to complete Passkey login')) } - if (!finish.data) { + if (!isAuthBundle(finish.data)) { throw new Error(t('Missing user data from Passkey login response')) } - await handleLoginSuccess( - finish.data as { id?: number } | null, - redirectTo - ) + await handleLoginSuccess(finish.data, redirectTo) toast.success(t('Signed in with Passkey')) } catch (error: unknown) { if (error instanceof DOMException && error.name === 'NotAllowedError') { diff --git a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx index d0674fed32c1..ec3e9517eacb 100644 --- a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { zodResolver } from '@hookform/resolvers/zod' import { Loader2 } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -50,6 +50,7 @@ import { saveAffiliateCode, } from '@/features/auth/lib/storage' import { useStatus } from '@/hooks/use-status' +import { isAuthBundle } from '@/lib/api' import { cn } from '@/lib/utils' export function SignUpForm({ @@ -63,6 +64,7 @@ export function SignUpForm({ const [wechatCode, setWeChatCode] = useState('') const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false) const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false) + const [turnstileWidgetKey, setTurnstileWidgetKey] = useState(0) const legalConsentErrorMessage = t('Please agree to the legal terms first') const { status } = useStatus() @@ -172,7 +174,7 @@ export function SignUpForm({ } else { toast.error(res?.message || t('Failed to create account')) } - } catch (_error) { + } catch { // Errors are handled by global interceptor } finally { setIsLoading(false) @@ -180,7 +182,10 @@ export function SignUpForm({ } async function handleSendVerificationCode() { - await sendCode(emailValue || '') + if (await sendCode(emailValue || '')) { + setTurnstileToken('') + setTurnstileWidgetKey((current) => current + 1) + } } const handleOpenWeChatDialog = () => { @@ -209,20 +214,29 @@ export function SignUpForm({ setIsWeChatSubmitting(true) try { const res = await wechatLoginByCode(wechatCode) - if (res?.success) { - await handleLoginSuccess(res.data as { id?: number } | null) + if (res?.success && isAuthBundle(res.data)) { + await handleLoginSuccess(res.data) toast.success(t('Signed in via WeChat')) handleWeChatDialogChange(false) } else { toast.error(res?.message || t('Login failed')) } - } catch (_error) { + } catch { toast.error(t('Login failed')) } finally { setIsWeChatSubmitting(false) } } + let verificationCodeAction: ReactNode = t('Send code') + if (isActive) { + verificationCodeAction = t('Resend ({{seconds}}s)', { + seconds: secondsLeft, + }) + } else if (isSendingCode) { + verificationCodeAction = + } + return (
- {isActive ? ( - t('Resend ({{seconds}}s)', { seconds: secondsLeft }) - ) : isSendingCode ? ( - - ) : ( - t('Send code') - )} + {verificationCodeAction} @@ -339,6 +347,7 @@ export function SignUpForm({ {isTurnstileEnabled && (
diff --git a/web/default/src/features/auth/types.ts b/web/default/src/features/auth/types.ts index 21ab480bd189..e65605ccab2d 100644 --- a/web/default/src/features/auth/types.ts +++ b/web/default/src/features/auth/types.ts @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import type { User } from '@/features/users/types' +import type { AuthBundle } from '@/stores/auth-store' // ============================================================================ // API Payloads @@ -30,6 +30,7 @@ export interface LoginPayload { export interface TwoFAPayload { code: string + flow_token: string } export interface RegisterPayload { @@ -63,22 +64,25 @@ export interface BindEmailPayload { export interface LoginResponse { success: boolean message: string - data?: { - require_2fa?: boolean - id?: number - } + data?: + | AuthBundle + | { + require_2fa?: boolean + flow_token?: string + expires_at?: number + } } export interface Login2FAResponse { success: boolean message: string - data?: User + data?: AuthBundle } -export interface ApiResponse { +export interface ApiResponse { success: boolean message: string - data?: unknown + data?: T } // ============================================================================ diff --git a/web/default/src/features/channels/api.ts b/web/default/src/features/channels/api.ts index 09bad1f94b44..2818242d08c9 100644 --- a/web/default/src/features/channels/api.ts +++ b/web/default/src/features/channels/api.ts @@ -295,13 +295,14 @@ export async function deleteDisabledChannels(): Promise<{ */ export async function getChannelKey( id: number, - code?: string + proofToken?: string ): Promise<{ success: boolean; message?: string; data?: { key: string } }> { - const payload = code ? { code } : undefined const res = await api.post( `/api/channel/${id}/key`, - payload, - channelActionConfig() + undefined, + channelActionConfig({ + headers: proofToken ? { 'X-Security-Proof': proofToken } : undefined, + }) ) return res.data } diff --git a/web/default/src/features/channels/components/dialogs/ollama-models-dialog.tsx b/web/default/src/features/channels/components/dialogs/ollama-models-dialog.tsx index 3157fb9cf1b1..f3772273eddb 100644 --- a/web/default/src/features/channels/components/dialogs/ollama-models-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/ollama-models-dialog.tsx @@ -39,7 +39,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Progress } from '@/components/ui/progress' import { Separator } from '@/components/ui/separator' -import { getCommonHeaders } from '@/lib/api' +import { getFreshAuthHeaders } from '@/lib/api' import { deleteOllamaModel, @@ -245,11 +245,12 @@ export function OllamaModelsDialog({ setPullProgress({ status: 'starting', completed: 0, total: 0 }) try { + const authHeaders = await getFreshAuthHeaders() const response = await fetch('/api/channel/ollama/pull/stream', { method: 'POST', credentials: 'include', headers: { - ...getCommonHeaders(), + ...authHeaders, Accept: 'text/event-stream', }, body: JSON.stringify({ diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 866599aa2179..f1bef8d60463 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -1341,43 +1341,48 @@ export function ChannelMutateDrawer({ } } - const fetchChannelKey = useCallback(async () => { - if (!channelId) { - throw new Error('Channel is not selected') - } - - setIsChannelKeyLoading(true) - try { - const res = await getChannelKey(channelId) - if (!res.success) { - throw new Error(res.message || t('Failed to fetch channel key')) + const fetchChannelKey = useCallback( + async (proofToken?: string) => { + if (!channelId) { + throw new Error('Channel is not selected') } - const keyValue = res.data?.key ?? '' - setChannelKey(keyValue) - toast.success(t('Channel key unlocked')) - return res - } finally { - setIsChannelKeyLoading(false) - } - }, [channelId, t]) + setIsChannelKeyLoading(true) + try { + const res = await getChannelKey(channelId, proofToken) + if (!res.success) { + throw new Error(res.message || t('Failed to fetch channel key')) + } + + const keyValue = res.data?.key ?? '' + setChannelKey(keyValue) + toast.success(t('Channel key unlocked')) + return res + } finally { + setIsChannelKeyLoading(false) + } + }, + [channelId, t] + ) const handleRevealKey = useCallback(async () => { if (!channelId) return try { await withVerification(fetchChannelKey, { + scope: 'channel.key.read', preferredMethod: 'passkey', - title: 'Verify to view channel key', - description: - 'Use Passkey or 2FA to confirm your identity before revealing this channel key.', + title: t('Verify to view channel key'), + description: t( + 'Use Passkey or 2FA to confirm your identity before revealing this channel key.' + ), }) } catch (error) { if (error instanceof Error) { toast.error(error.message) } } - }, [channelId, withVerification, fetchChannelKey]) + }, [channelId, withVerification, fetchChannelKey, t]) const handleRefreshCodexCredential = useCallback(async () => { if (!channelId) return diff --git a/web/default/src/features/playground/hooks/use-chat-handler.ts b/web/default/src/features/playground/hooks/use-chat-handler.ts index bfc85f2c5b93..55f5fdc81314 100644 --- a/web/default/src/features/playground/hooks/use-chat-handler.ts +++ b/web/default/src/features/playground/hooks/use-chat-handler.ts @@ -47,6 +47,7 @@ const KNOWN_ERROR_MESSAGES = new Set(Object.values(ERROR_MESSAGES)) const STREAM_UPDATE_FLUSH_MS = 50 type PendingStreamChunks = { + generation: number content: string reasoning: string } @@ -74,66 +75,95 @@ export function useChatHandler({ const { sendStreamRequest, stopStream, isStreaming } = useStreamRequest() const [isRequesting, setIsRequesting] = useState(false) const abortControllerRef = useRef(null) - const requestIdRef = useRef(0) + const requestGenerationRef = useRef(0) const pendingStreamChunksRef = useRef({ + generation: 0, content: '', reasoning: '', }) const streamFlushTimerRef = useRef(null) - const flushStreamUpdates = useCallback(() => { + const discardPendingStreamUpdates = useCallback((generation: number) => { if (streamFlushTimerRef.current !== null) { window.clearTimeout(streamFlushTimerRef.current) streamFlushTimerRef.current = null } - - const pendingChunks = pendingStreamChunksRef.current - if (!pendingChunks.reasoning && !pendingChunks.content) { - return + pendingStreamChunksRef.current = { + generation, + content: '', + reasoning: '', } + }, []) - pendingStreamChunksRef.current = { content: '', reasoning: '' } - onMessageUpdate((prev) => - updateLastAssistantMessage(prev, (message) => { - let updatedMessage = message - - if (pendingChunks.reasoning) { - updatedMessage = applyStreamingChunk( - updatedMessage, - 'reasoning', - pendingChunks.reasoning - ) - } + const flushStreamUpdates = useCallback( + (generation: number) => { + if (generation !== requestGenerationRef.current) return + if (streamFlushTimerRef.current !== null) { + window.clearTimeout(streamFlushTimerRef.current) + streamFlushTimerRef.current = null + } - if (pendingChunks.content) { - updatedMessage = applyStreamingChunk( - updatedMessage, - 'content', - pendingChunks.content - ) - } + const pendingChunks = pendingStreamChunksRef.current + if (pendingChunks.generation !== generation) return + if (!pendingChunks.reasoning && !pendingChunks.content) { + return + } + + pendingStreamChunksRef.current = { + generation, + content: '', + reasoning: '', + } + onMessageUpdate((prev) => { + if (generation !== requestGenerationRef.current) return prev + return updateLastAssistantMessage(prev, (message) => { + let updatedMessage = message + + if (pendingChunks.reasoning) { + updatedMessage = applyStreamingChunk( + updatedMessage, + 'reasoning', + pendingChunks.reasoning + ) + } - return updatedMessage + if (pendingChunks.content) { + updatedMessage = applyStreamingChunk( + updatedMessage, + 'content', + pendingChunks.content + ) + } + + return updatedMessage + }) }) - ) - }, [onMessageUpdate]) + }, + [onMessageUpdate] + ) - const scheduleStreamFlush = useCallback(() => { - if (streamFlushTimerRef.current !== null) { - return - } + const scheduleStreamFlush = useCallback( + (generation: number) => { + if (generation !== requestGenerationRef.current) return + if (streamFlushTimerRef.current !== null) { + return + } - streamFlushTimerRef.current = window.setTimeout( - flushStreamUpdates, - STREAM_UPDATE_FLUSH_MS - ) - }, [flushStreamUpdates]) + streamFlushTimerRef.current = window.setTimeout(() => { + flushStreamUpdates(generation) + }, STREAM_UPDATE_FLUSH_MS) + }, + [flushStreamUpdates] + ) useEffect( () => () => { + requestGenerationRef.current += 1 if (streamFlushTimerRef.current !== null) { window.clearTimeout(streamFlushTimerRef.current) } + abortControllerRef.current?.abort() + abortControllerRef.current = null }, [] ) @@ -158,45 +188,54 @@ export function useChatHandler({ // Handle stream update const handleStreamUpdate = useCallback( - (type: 'reasoning' | 'content', chunk: string) => { + (generation: number, type: 'reasoning' | 'content', chunk: string) => { + if (generation !== requestGenerationRef.current) return + if (pendingStreamChunksRef.current.generation !== generation) return pendingStreamChunksRef.current[type] = mergePendingStreamChunk( pendingStreamChunksRef.current[type], chunk ) - scheduleStreamFlush() + scheduleStreamFlush(generation) }, [scheduleStreamFlush] ) // Handle stream complete - const handleStreamComplete = useCallback(() => { - flushStreamUpdates() - setIsRequesting(false) - onMessageUpdate((prev) => - updateLastAssistantMessage(prev, (message) => - isAssistantMessageFinal(message) - ? message - : completeAssistantMessage(message) - ) - ) - }, [flushStreamUpdates, onMessageUpdate]) + const handleStreamComplete = useCallback( + (generation: number) => { + if (generation !== requestGenerationRef.current) return + flushStreamUpdates(generation) + setIsRequesting(false) + onMessageUpdate((prev) => { + if (generation !== requestGenerationRef.current) return prev + return updateLastAssistantMessage(prev, (message) => + isAssistantMessageFinal(message) + ? message + : completeAssistantMessage(message) + ) + }) + }, + [flushStreamUpdates, onMessageUpdate] + ) // Handle stream error const handleStreamError = useCallback( - (error: string, errorCode?: string) => { - flushStreamUpdates() + (generation: number, error: string, errorCode?: string) => { + if (generation !== requestGenerationRef.current) return + flushStreamUpdates(generation) setIsRequesting(false) const displayError = getDisplayError(error) toast.error(displayError) const errorTitle = t(ERROR_MESSAGES.API_REQUEST_ERROR) - onMessageUpdate((prev) => - updateAssistantMessageWithError( + onMessageUpdate((prev) => { + if (generation !== requestGenerationRef.current) return prev + return updateAssistantMessageWithError( prev, displayError, errorCode, errorTitle ) - ) + }) }, [flushStreamUpdates, getDisplayError, onMessageUpdate, t] ) @@ -204,23 +243,29 @@ export function useChatHandler({ // Send streaming chat request const sendStreamingChat = useCallback( (messages: Message[]) => { + const generation = requestGenerationRef.current + 1 + requestGenerationRef.current = generation + abortControllerRef.current?.abort() + abortControllerRef.current = null + discardPendingStreamUpdates(generation) setIsRequesting(true) const payload = buildChatCompletionPayload( messages, config, parameterEnabled ) - sendStreamRequest( + void sendStreamRequest( payload, - handleStreamUpdate, - handleStreamComplete, - handleStreamError + (type, chunk) => handleStreamUpdate(generation, type, chunk), + () => handleStreamComplete(generation), + (error, errorCode) => handleStreamError(generation, error, errorCode) ) }, [ config, parameterEnabled, sendStreamRequest, + discardPendingStreamUpdates, handleStreamUpdate, handleStreamComplete, handleStreamError, @@ -235,10 +280,13 @@ export function useChatHandler({ config, parameterEnabled ) - const requestId = requestIdRef.current + 1 + const generation = requestGenerationRef.current + 1 const abortController = new AbortController() - requestIdRef.current = requestId + requestGenerationRef.current = generation + stopStream() + discardPendingStreamUpdates(generation) + abortControllerRef.current?.abort() abortControllerRef.current = abortController try { @@ -247,15 +295,21 @@ export function useChatHandler({ payload, abortController.signal ) - if (abortController.signal.aborted) return + if ( + abortController.signal.aborted || + requestGenerationRef.current !== generation + ) { + return + } if (!hasChatCompletionChoice(response)) { - handleStreamError(ERROR_MESSAGES.API_REQUEST_ERROR) + handleStreamError(generation, ERROR_MESSAGES.API_REQUEST_ERROR) return } - onMessageUpdate((prev) => - updateLastAssistantMessage(prev, (message) => { + onMessageUpdate((prev) => { + if (requestGenerationRef.current !== generation) return prev + return updateLastAssistantMessage(prev, (message) => { const updatedMessage = applyChatCompletionResponse( message, response @@ -263,20 +317,32 @@ export function useChatHandler({ return updatedMessage ?? message }) - ) + }) } catch (error: unknown) { - if (abortController.signal.aborted) return + if ( + abortController.signal.aborted || + requestGenerationRef.current !== generation + ) { + return + } const { errorCode, errorMessage } = parseRequestErrorDetails(error) - handleStreamError(errorMessage, errorCode) + handleStreamError(generation, errorMessage, errorCode) } finally { - if (requestIdRef.current === requestId) { + if (requestGenerationRef.current === generation) { abortControllerRef.current = null setIsRequesting(false) } } }, - [config, parameterEnabled, onMessageUpdate, handleStreamError] + [ + config, + parameterEnabled, + stopStream, + discardPendingStreamUpdates, + onMessageUpdate, + handleStreamError, + ] ) // Send chat request (stream or non-stream based on config) @@ -293,19 +359,29 @@ export function useChatHandler({ // Stop generation const stopGeneration = useCallback(() => { + const stoppedGeneration = requestGenerationRef.current + flushStreamUpdates(stoppedGeneration) + const idleGeneration = stoppedGeneration + 1 + requestGenerationRef.current = idleGeneration + discardPendingStreamUpdates(idleGeneration) stopStream() - flushStreamUpdates() abortControllerRef.current?.abort() abortControllerRef.current = null setIsRequesting(false) - onMessageUpdate((prev) => - updateLastAssistantMessage(prev, (message) => + onMessageUpdate((prev) => { + if (requestGenerationRef.current !== idleGeneration) return prev + return updateLastAssistantMessage(prev, (message) => isAssistantMessagePending(message) ? completeAssistantMessage(message) : message ) - ) - }, [stopStream, flushStreamUpdates, onMessageUpdate]) + }) + }, [ + stopStream, + flushStreamUpdates, + discardPendingStreamUpdates, + onMessageUpdate, + ]) return { sendChat, diff --git a/web/default/src/features/playground/hooks/use-stream-request.test.ts b/web/default/src/features/playground/hooks/use-stream-request.test.ts new file mode 100644 index 000000000000..a913fec3fe11 --- /dev/null +++ b/web/default/src/features/playground/hooks/use-stream-request.test.ts @@ -0,0 +1,200 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import type { ChatCompletionRequest } from '../types' +import { createStreamRequestController } from './use-stream-request' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +class FakeStreamSource { + readyState = 0 + closed = false + streamed = false + private listeners = new Map< + string, + Array<(event: Event & { data?: string; readyState?: number }) => void> + >() + + addEventListener( + type: string, + listener: (event: Event & { data?: string; readyState?: number }) => void + ) { + const listeners = this.listeners.get(type) ?? [] + listeners.push(listener) + this.listeners.set(type, listeners) + } + + close() { + this.closed = true + } + + stream() { + this.streamed = true + } + + emit(type: string, data?: string) { + for (const listener of this.listeners.get(type) ?? []) { + listener({ data, readyState: this.readyState } as Event & { + data?: string + readyState?: number + }) + } + } +} + +const payload: ChatCompletionRequest = { + model: 'test-model', + messages: [{ role: 'user', content: 'hello' }], + stream: true, +} + +const noopCallbacks = { + onUpdate: () => undefined, + onComplete: () => undefined, + onError: () => undefined, +} + +describe('latest-wins stream request coordination', () => { + test('only creates a stream for the latest header request', async () => { + const firstHeaders = deferred>() + const secondHeaders = deferred>() + let headerRequest = 0 + const sources: FakeStreamSource[] = [] + const controller = createStreamRequestController({ + getHeaders: () => { + headerRequest += 1 + return headerRequest === 1 + ? firstHeaders.promise + : secondHeaders.promise + }, + createSource: () => { + const source = new FakeStreamSource() + sources.push(source) + return source + }, + setStreaming: () => undefined, + }) + + const first = controller.send(payload, noopCallbacks) + const second = controller.send(payload, noopCallbacks) + firstHeaders.resolve({ Authorization: 'Bearer stale' }) + await first + assert.equal(sources.length, 0) + + secondHeaders.resolve({ Authorization: 'Bearer current' }) + await second + assert.equal(sources.length, 1) + assert.equal(sources[0]?.streamed, true) + }) + + test('stop cancels a request that is still waiting for headers', async () => { + const headers = deferred>() + let sourceCount = 0 + const controller = createStreamRequestController({ + getHeaders: () => headers.promise, + createSource: () => { + sourceCount += 1 + return new FakeStreamSource() + }, + setStreaming: () => undefined, + }) + + const request = controller.send(payload, noopCallbacks) + controller.stop() + headers.resolve({ Authorization: 'Bearer ignored' }) + await request + + assert.equal(sourceCount, 0) + }) + + test('dispose cancels a pending header request without a state update', async () => { + const headers = deferred>() + const streamingStates: boolean[] = [] + let sourceCount = 0 + const controller = createStreamRequestController({ + getHeaders: () => headers.promise, + createSource: () => { + sourceCount += 1 + return new FakeStreamSource() + }, + setStreaming: (streaming) => streamingStates.push(streaming), + }) + + const request = controller.send(payload, noopCallbacks) + controller.dispose() + headers.resolve({ Authorization: 'Bearer ignored' }) + await request + + assert.equal(sourceCount, 0) + assert.deepEqual(streamingStates, [false]) + }) + + test('closes the previous source and ignores all of its later events', async () => { + const nextHeaders = deferred>() + let headerRequest = 0 + const sources: FakeStreamSource[] = [] + const updates: string[] = [] + const controller = createStreamRequestController({ + getHeaders: () => { + headerRequest += 1 + if (headerRequest === 1) { + return Promise.resolve({ Authorization: 'Bearer first' }) + } + return nextHeaders.promise + }, + createSource: () => { + const source = new FakeStreamSource() + sources.push(source) + return source + }, + setStreaming: () => undefined, + }) + const callbacks = { + onUpdate: (_type: 'reasoning' | 'content', chunk: string) => + updates.push(chunk), + onComplete: () => undefined, + onError: () => undefined, + } + + await controller.send(payload, callbacks) + const second = controller.send(payload, callbacks) + assert.equal(sources[0]?.closed, true) + sources[0]?.emit( + 'message', + JSON.stringify({ choices: [{ delta: { content: 'stale' } }] }) + ) + + nextHeaders.resolve({ Authorization: 'Bearer second' }) + await second + sources[1]?.emit( + 'message', + JSON.stringify({ choices: [{ delta: { content: 'current' } }] }) + ) + + assert.deepEqual(updates, ['current']) + }) +}) diff --git a/web/default/src/features/playground/hooks/use-stream-request.ts b/web/default/src/features/playground/hooks/use-stream-request.ts index 760fbd6cd3b2..f51b0f494065 100644 --- a/web/default/src/features/playground/hooks/use-stream-request.ts +++ b/web/default/src/features/playground/hooks/use-stream-request.ts @@ -16,10 +16,10 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { SSE } from 'sse.js' -import { getCommonHeaders } from '@/lib/api' +import { getFreshAuthHeaders } from '@/lib/api' import { API_ENDPOINTS, ERROR_MESSAGES } from '../constants' import { @@ -31,107 +31,201 @@ import { } from '../lib' import type { ChatCompletionRequest } from '../types' -/** - * Hook for handling streaming chat completion requests - */ -export function useStreamRequest() { - const sseSourceRef = useRef(null) - const isStreamCompleteRef = useRef(false) - const [isStreaming, setIsStreaming] = useState(false) +interface StreamEventSource { + readyState?: number + addEventListener: ( + type: string, + listener: (event: Event & { data?: string; readyState?: number }) => void + ) => void + close: () => void + stream: () => void +} + +interface StreamRequestCallbacks { + onUpdate: (type: 'reasoning' | 'content', chunk: string) => void + onComplete: () => void + onError: (error: string, errorCode?: string) => void +} - const closeActiveStream = useCallback((source?: SSE) => { - const streamSource = source ?? sseSourceRef.current - streamSource?.close() +interface StreamRequestControllerRuntime { + getHeaders: () => Promise> + createSource: ( + payload: ChatCompletionRequest, + headers: Record + ) => StreamEventSource + setStreaming: (streaming: boolean) => void +} - if (!source || sseSourceRef.current === source) { - sseSourceRef.current = null - setIsStreaming(false) +export function createStreamRequestController( + runtime: StreamRequestControllerRuntime +) { + let source: StreamEventSource | null = null + let generation = 0 + + const closeActiveSource = (target: StreamEventSource) => { + target.close() + if (source === target) { + source = null + runtime.setStreaming(false) } - }, []) + } - const sendStreamRequest = useCallback( - ( - payload: ChatCompletionRequest, - onUpdate: (type: 'reasoning' | 'content', chunk: string) => void, - onComplete: () => void, - onError: (error: string, errorCode?: string) => void - ) => { - sseSourceRef.current?.close() - - const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, { - headers: getCommonHeaders(), - method: 'POST', - payload: JSON.stringify(payload), - }) - - sseSourceRef.current = source - isStreamCompleteRef.current = false - setIsStreaming(true) - - const handleError = (errorMessage: string, errorCode?: string) => { - if (!isStreamCompleteRef.current) { - onError(errorMessage, errorCode) - closeActiveStream(source) - } - } + const send = async ( + payload: ChatCompletionRequest, + callbacks: StreamRequestCallbacks + ) => { + const requestGeneration = generation + 1 + generation = requestGeneration + const previousSource = source + source = null + previousSource?.close() + runtime.setStreaming(false) + + let headers: Record + try { + headers = await runtime.getHeaders() + } catch (error: unknown) { + if (generation !== requestGeneration) return + callbacks.onError( + error instanceof Error + ? error.message + : ERROR_MESSAGES.STREAM_START_ERROR + ) + return + } + if (generation !== requestGeneration) return - source.addEventListener('message', (e: MessageEvent) => { - if (isStreamDoneMessage(e.data)) { - isStreamCompleteRef.current = true - closeActiveStream(source) - onComplete() - return - } + const nextSource = runtime.createSource(payload, headers) + source = nextSource + runtime.setStreaming(true) + let completed = false - try { - const updates = parseStreamMessageUpdates(e.data) + const isCurrent = () => + generation === requestGeneration && source === nextSource - for (const update of updates) { - onUpdate(update.type, update.chunk) - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to parse SSE message:', error) - handleError(ERROR_MESSAGES.PARSE_ERROR) - } - }) - - source.addEventListener('error', (e: Event & { data?: string }) => { - // Only handle errors if stream didn't complete normally - if (!isStreamClosedReadyState(source.readyState)) { - // eslint-disable-next-line no-console - console.error('SSE Error:', e) - const { errorCode, errorMessage } = parseStreamErrorDetails(e.data) - handleError(errorMessage, errorCode) - } - }) + const handleError = (errorMessage: string, errorCode?: string) => { + if (!isCurrent() || completed) return + completed = true + callbacks.onError(errorMessage, errorCode) + closeActiveSource(nextSource) + } + + nextSource.addEventListener('message', (event) => { + if (!isCurrent() || completed) return + const data = event.data ?? '' + if (isStreamDoneMessage(data)) { + completed = true + closeActiveSource(nextSource) + callbacks.onComplete() + return + } - source.addEventListener( - 'readystatechange', - (e: Event & { readyState?: number }) => { - const errorMessage = getStreamReadyStateError(e.readyState, source) + try { + const updates = parseStreamMessageUpdates(data) - if (errorMessage) { - handleError(errorMessage) - } + for (const update of updates) { + callbacks.onUpdate(update.type, update.chunk) } - ) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to parse SSE message:', error) + handleError(ERROR_MESSAGES.PARSE_ERROR) + } + }) - try { - source.stream() - } catch (error: unknown) { + nextSource.addEventListener('error', (event) => { + if (!isCurrent() || completed) return + if (!isStreamClosedReadyState(nextSource.readyState)) { // eslint-disable-next-line no-console - console.error('Failed to start SSE stream:', error) - onError(ERROR_MESSAGES.STREAM_START_ERROR) - closeActiveStream(source) + console.error('SSE Error:', event) + const { errorCode, errorMessage } = parseStreamErrorDetails(event.data) + handleError(errorMessage, errorCode) } - }, - [closeActiveStream] + }) + + nextSource.addEventListener('readystatechange', (event) => { + if (!isCurrent() || completed) return + const errorMessage = getStreamReadyStateError( + event.readyState, + nextSource + ) + + if (errorMessage) { + handleError(errorMessage) + } + }) + + try { + if (!isCurrent()) return + nextSource.stream() + } catch (error: unknown) { + if (!isCurrent() || completed) return + // eslint-disable-next-line no-console + console.error('Failed to start SSE stream:', error) + handleError(ERROR_MESSAGES.STREAM_START_ERROR) + } + } + + const cancel = (notify: boolean) => { + generation += 1 + const activeSource = source + source = null + activeSource?.close() + if (notify) runtime.setStreaming(false) + } + + const stop = () => cancel(true) + const dispose = () => cancel(false) + + return { send, stop, dispose } +} + +/** + * Hook for handling streaming chat completion requests + */ +export function useStreamRequest() { + const [isStreaming, setIsStreaming] = useState(false) + const controllerRef = useRef | null>(null) + if (!controllerRef.current) { + controllerRef.current = createStreamRequestController({ + getHeaders: getFreshAuthHeaders, + createSource: (payload, headers) => + new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, { + headers, + method: 'POST', + payload: JSON.stringify(payload), + }) as StreamEventSource, + setStreaming: setIsStreaming, + }) + } + + const sendStreamRequest = useCallback( + ( + payload: ChatCompletionRequest, + onUpdate: (type: 'reasoning' | 'content', chunk: string) => void, + onComplete: () => void, + onError: (error: string, errorCode?: string) => void + ) => + controllerRef.current?.send(payload, { + onUpdate, + onComplete, + onError, + }), + [] ) const stopStream = useCallback(() => { - closeActiveStream() - }, [closeActiveStream]) + controllerRef.current?.stop() + }, []) + + useEffect( + () => () => { + controllerRef.current?.dispose() + }, + [] + ) return { sendStreamRequest, diff --git a/web/default/src/features/profile/api.ts b/web/default/src/features/profile/api.ts index d453b25a51e0..95ad0720bd97 100644 --- a/web/default/src/features/profile/api.ts +++ b/web/default/src/features/profile/api.ts @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { api } from '@/lib/api' +import type { LoginSession } from '@/stores/auth-store' import type { ApiResponse, @@ -46,7 +47,9 @@ export async function getUserProfile(): Promise> { export async function updateUserProfile( data: UpdateUserRequest ): Promise { - const res = await api.put('/api/user/self', data) + const res = await api.put('/api/user/self', data, { + acceptAuthRotation: Boolean(data.password), + }) return res.data } @@ -125,7 +128,39 @@ export async function bindEmail( * Bind WeChat account */ export async function bindWeChat(code: string): Promise { - const res = await api.get(`/api/oauth/wechat/bind?code=${code}`) + const res = await api.post('/api/oauth/wechat/bind', { code }) + return res.data +} + +export interface TelegramBindFlow { + flow_token: string + callback_url: string + expires_at: number +} + +export async function startTelegramBind(): Promise< + ApiResponse +> { + const res = await api.post('/api/oauth/telegram/bind/start') + return res.data +} + +// ============================================================================ +// Login Session APIs +// ============================================================================ + +export async function getLoginSessions(): Promise> { + const res = await api.get('/api/user/sessions') + return res.data +} + +export async function revokeLoginSession(sid: string): Promise { + const res = await api.delete(`/api/user/sessions/${encodeURIComponent(sid)}`) + return res.data +} + +export async function revokeOtherLoginSessions(): Promise { + const res = await api.post('/api/user/sessions/revoke-others') return res.data } diff --git a/web/default/src/features/profile/components/dialogs/delete-account-dialog.tsx b/web/default/src/features/profile/components/dialogs/delete-account-dialog.tsx index b7732c157b91..0808dc63e47b 100644 --- a/web/default/src/features/profile/components/dialogs/delete-account-dialog.tsx +++ b/web/default/src/features/profile/components/dialogs/delete-account-dialog.tsx @@ -27,8 +27,8 @@ import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { api } from '@/lib/api' -import { useAuthStore } from '@/stores/auth-store' +import { logout } from '@/features/auth/api' +import { clearAuthentication } from '@/lib/api' import { deleteUserAccount } from '../../api' @@ -49,7 +49,6 @@ export function DeleteAccountDialog({ }: DeleteAccountDialogProps) { const { t } = useTranslation() const navigate = useNavigate() - const { reset } = useAuthStore((state) => state.auth) const [loading, setLoading] = useState(false) const [confirmation, setConfirmation] = useState('') @@ -68,18 +67,17 @@ export function DeleteAccountDialog({ // Logout and redirect try { - await api.get('/api/user/logout') + await logout() } catch { // Ignore logout errors } - reset() - localStorage.removeItem('user') + clearAuthentication() navigate({ to: '/sign-in' }) } else { toast.error(response.message || t('Failed to delete account')) } - } catch (_error) { + } catch { toast.error(t('Failed to delete account')) } finally { setLoading(false) diff --git a/web/default/src/features/profile/components/dialogs/telegram-bind-dialog.tsx b/web/default/src/features/profile/components/dialogs/telegram-bind-dialog.tsx index e066e7d10e68..0c9dd6bfcc2c 100644 --- a/web/default/src/features/profile/components/dialogs/telegram-bind-dialog.tsx +++ b/web/default/src/features/profile/components/dialogs/telegram-bind-dialog.tsx @@ -17,10 +17,17 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { Send } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import { Dialog } from '@/components/dialog' import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { TELEGRAM_BIND_RESULT_MESSAGE } from '@/features/auth/constants' + +import { startTelegramBind } from '../../api' // ============================================================================ // Telegram Bind Dialog Component @@ -37,8 +44,98 @@ export function TelegramBindDialog({ open, onOpenChange, botName, + onSuccess, }: TelegramBindDialogProps) { const { t } = useTranslation() + const widgetRef = useRef(null) + const [callbackUrl, setCallbackUrl] = useState(null) + const [flowToken, setFlowToken] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const createBindFlow = useCallback(async () => { + setLoading(true) + setError(null) + try { + const response = await startTelegramBind() + if (!response.success || !response.data?.callback_url) { + throw new Error( + response.message || t('Failed to start Telegram binding') + ) + } + setFlowToken(response.data.flow_token) + setCallbackUrl( + new URL(response.data.callback_url, window.location.origin).toString() + ) + } catch (bindError: unknown) { + setError( + bindError instanceof Error + ? bindError.message + : t('Failed to start Telegram binding') + ) + } finally { + setLoading(false) + } + }, [t]) + + useEffect(() => { + if (!open) { + setCallbackUrl(null) + setFlowToken(null) + setError(null) + return + } + void createBindFlow() + }, [createBindFlow, open]) + + useEffect(() => { + if (!open || !flowToken) return + + const handleBindResult = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return + const result = event.data as { + type?: string + flow_token?: string + success?: boolean + message?: string + } | null + if ( + !result || + result.type !== TELEGRAM_BIND_RESULT_MESSAGE || + result.flow_token !== flowToken + ) { + return + } + if (!result.success) { + setError(result.message || t('Failed to start Telegram binding')) + return + } + toast.success(t('Binding successful!')) + onSuccess() + onOpenChange(false) + } + + window.addEventListener('message', handleBindResult) + return () => window.removeEventListener('message', handleBindResult) + }, [flowToken, onOpenChange, onSuccess, open, t]) + + useEffect(() => { + const container = widgetRef.current + if (!container || !callbackUrl) return + + container.replaceChildren() + const script = document.createElement('script') + script.async = true + script.src = 'https://telegram.org/js/telegram-widget.js?22' + script.setAttribute('data-telegram-login', botName.replace(/^@/, '')) + script.setAttribute('data-size', 'large') + script.setAttribute('data-auth-url', callbackUrl) + script.setAttribute('data-request-access', 'write') + container.appendChild(script) + + return () => container.replaceChildren() + }, [botName, callbackUrl]) + return (
- {/* Telegram Login Widget will be injected here by react-telegram-login */} -
- {/* This would require the react-telegram-login library */} -
- {t('Telegram Login Widget')} + {loading && } + {error && ( +
+

{error}

+
-
+ )} +

diff --git a/web/default/src/features/profile/components/login-session-dialogs.tsx b/web/default/src/features/profile/components/login-session-dialogs.tsx new file mode 100644 index 000000000000..4e10ed5719c0 --- /dev/null +++ b/web/default/src/features/profile/components/login-session-dialogs.tsx @@ -0,0 +1,80 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useTranslation } from 'react-i18next' + +import { ConfirmDialog } from '@/components/confirm-dialog' +import type { LoginSession } from '@/stores/auth-store' + +interface LoginSessionDialogsProps { + revokeTarget: LoginSession | null + confirmOthers: boolean + revoking: boolean + revokingOthers: boolean + onRevokeTargetChange: (session: LoginSession | null) => void + onConfirmOthersChange: (open: boolean) => void + onRevoke: () => void + onRevokeOthers: () => void +} + +export function LoginSessionDialogs({ + revokeTarget, + confirmOthers, + revoking, + revokingOthers, + onRevokeTargetChange, + onConfirmOthersChange, + onRevoke, + onRevokeOthers, +}: LoginSessionDialogsProps) { + const { t } = useTranslation() + + return ( + <> + !open && onRevokeTargetChange(null)} + title={ + revokeTarget?.current + ? t('Sign out this device?') + : t('Revoke session?') + } + desc={t( + 'This session will lose access immediately and must sign in again.' + )} + confirmText={revokeTarget?.current ? t('Sign out') : t('Revoke')} + destructive + isLoading={revoking} + handleConfirm={onRevoke} + /> + + + + ) +} diff --git a/web/default/src/features/profile/components/login-session-item.tsx b/web/default/src/features/profile/components/login-session-item.tsx new file mode 100644 index 000000000000..f3b8de54aff9 --- /dev/null +++ b/web/default/src/features/profile/components/login-session-item.tsx @@ -0,0 +1,77 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { LaptopIcon } from '@hugeicons/core-free-icons' +import { HugeiconsIcon } from '@hugeicons/react' +import { useTranslation } from 'react-i18next' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import dayjs from '@/lib/dayjs' +import type { LoginSession } from '@/stores/auth-store' + +import { loginMethodLabel, sessionDevice } from './login-session-utils' + +interface LoginSessionItemProps { + session: LoginSession + onRevoke: (session: LoginSession) => void +} + +export function LoginSessionItem({ session, onRevoke }: LoginSessionItemProps) { + const { t } = useTranslation() + + return ( +

+
+ +
+
+
+

+ {sessionDevice( + session.user_agent, + t('Unknown device'), + t('Browser') + )} +

+ {session.current && {t('Current')}} +
+

+ {t('IP: {{ip}} · Method: {{method}}', { + ip: session.ip || t('Unknown'), + method: loginMethodLabel(session.login_method, t), + })} +

+

+ {t('Last active {{time}} · Expires {{expires}}', { + time: dayjs.unix(session.last_active_at).fromNow(), + expires: dayjs.unix(session.expires_at).format('YYYY-MM-DD HH:mm'), + })} +

+
+ +
+ ) +} diff --git a/web/default/src/features/profile/components/login-session-utils.test.ts b/web/default/src/features/profile/components/login-session-utils.test.ts new file mode 100644 index 000000000000..ec0b7161a6a9 --- /dev/null +++ b/web/default/src/features/profile/components/login-session-utils.test.ts @@ -0,0 +1,56 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import type { TFunction } from 'i18next' + +import { loginMethodLabel, sessionDevice } from './login-session-utils' + +const translate = ((key: string) => key) as TFunction + +describe('login session presentation', () => { + test('labels built-in and provider OAuth login methods', () => { + assert.equal(loginMethodLabel('password', translate), 'Password') + assert.equal( + loginMethodLabel('2fa', translate), + 'Two-factor Authentication' + ) + assert.equal(loginMethodLabel('oauth:github', translate), 'OAuth · GitHub') + assert.equal( + loginMethodLabel('oauth:custom-provider', translate), + 'OAuth · custom-provider' + ) + }) + + test('derives a stable browser and operating-system label', () => { + assert.equal( + sessionDevice( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit Safari/605.1.15', + 'Unknown device', + 'Browser' + ), + 'Safari · macOS' + ) + assert.equal( + sessionDevice('', 'Unknown device', 'Browser'), + 'Unknown device' + ) + }) +}) diff --git a/web/default/src/features/profile/components/login-session-utils.ts b/web/default/src/features/profile/components/login-session-utils.ts new file mode 100644 index 000000000000..bb7fcbfdf7c8 --- /dev/null +++ b/web/default/src/features/profile/components/login-session-utils.ts @@ -0,0 +1,74 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { TFunction } from 'i18next' + +export function sessionDevice( + userAgent: string, + unknownDevice: string, + browserLabel: string +): string { + if (!userAgent) return unknownDevice + let browser = browserLabel + if (userAgent.includes('Edg/')) browser = 'Edge' + else if (userAgent.includes('Chrome/')) browser = 'Chrome' + else if (userAgent.includes('Firefox/')) browser = 'Firefox' + else if (userAgent.includes('Safari/')) browser = 'Safari' + + let system = '' + if (userAgent.includes('Windows')) system = 'Windows' + else if (userAgent.includes('Mac OS')) system = 'macOS' + else if (userAgent.includes('Android')) system = 'Android' + else if (userAgent.includes('iPhone') || userAgent.includes('iPad')) { + system = 'iOS' + } else if (userAgent.includes('Linux')) system = 'Linux' + return system ? `${browser} · ${system}` : browser +} + +export function loginMethodLabel(method: string, t: TFunction): string { + const normalized = method.trim().toLowerCase() + switch (normalized) { + case 'password': + return t('Password') + case '2fa': + return t('Two-factor Authentication') + case 'passkey': + return t('Passkey') + case 'wechat': + return t('WeChat') + case 'telegram': + return t('Telegram') + case 'oauth': + return t('OAuth') + case 'unknown': + case '': + return t('Unknown') + default: + break + } + + if (!normalized.startsWith('oauth:')) return method + const provider = normalized.slice('oauth:'.length) + const providerNames: Record = { + discord: 'Discord', + github: 'GitHub', + linuxdo: 'LinuxDO', + oidc: 'OIDC', + } + return `${t('OAuth')} · ${providerNames[provider] || provider}` +} diff --git a/web/default/src/features/profile/components/login-sessions-card.tsx b/web/default/src/features/profile/components/login-sessions-card.tsx new file mode 100644 index 000000000000..8af2b5f0dcca --- /dev/null +++ b/web/default/src/features/profile/components/login-sessions-card.tsx @@ -0,0 +1,212 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { Logout01Icon, SmartPhone01Icon } from '@hugeicons/core-free-icons' +import { HugeiconsIcon } from '@hugeicons/react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty' +import { Separator } from '@/components/ui/separator' +import { Skeleton } from '@/components/ui/skeleton' +import { clearAuthentication } from '@/lib/api' +import type { LoginSession } from '@/stores/auth-store' + +import { + getLoginSessions, + revokeLoginSession, + revokeOtherLoginSessions, +} from '../api' +import { LoginSessionDialogs } from './login-session-dialogs' +import { LoginSessionItem } from './login-session-item' + +const sessionQueryKey = ['profile', 'login-sessions'] as const + +export function LoginSessionsCard() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [revokeTarget, setRevokeTarget] = useState(null) + const [confirmOthers, setConfirmOthers] = useState(false) + + const sessionsQuery = useQuery({ + queryKey: sessionQueryKey, + queryFn: async () => { + const response = await getLoginSessions() + if (!response.success) { + throw new Error(response.message || t('Failed to load login sessions')) + } + return response.data ?? [] + }, + }) + + const revokeMutation = useMutation({ + mutationFn: async (sid: string) => { + const response = await revokeLoginSession(sid) + if (!response.success) { + throw new Error(response.message || t('Failed to sign out session')) + } + return sid + }, + onSuccess: async (sid) => { + const revokedCurrent = sessionsQuery.data?.some( + (session) => session.sid === sid && session.current + ) + setRevokeTarget(null) + if (revokedCurrent) { + clearAuthentication() + window.location.replace('/sign-in') + return + } + toast.success(t('Session signed out')) + await queryClient.invalidateQueries({ queryKey: sessionQueryKey }) + }, + onError: (error: Error) => toast.error(error.message), + }) + + const revokeOthersMutation = useMutation({ + mutationFn: async () => { + const response = await revokeOtherLoginSessions() + if (!response.success) { + throw new Error( + response.message || t('Failed to sign out other sessions') + ) + } + }, + onSuccess: async () => { + setConfirmOthers(false) + toast.success(t('Other sessions signed out')) + await queryClient.invalidateQueries({ queryKey: sessionQueryKey }) + }, + onError: (error: Error) => toast.error(error.message), + }) + + const sessions = sessionsQuery.data ?? [] + const hasOtherSessions = sessions.some((session) => !session.current) + let sessionsContent: ReactNode + if (sessionsQuery.isLoading) { + sessionsContent = ( +
+ + +
+ ) + } else if (sessionsQuery.isError) { + sessionsContent = ( + + + + + + {t('Unable to load login sessions')} + + {t('Refresh the list and try again.')} + + + + + ) + } else if (sessions.length === 0) { + sessionsContent = ( + + + + + + {t('No active login sessions')} + + + ) + } else { + sessionsContent = ( +
+ {sessions.map((session, index) => ( +
+ {index > 0 && } + +
+ ))} +
+ ) + } + + return ( + <> + + + {t('Login sessions')} + + {t('Review and sign out devices currently using your account.')} + + + + + + {sessionsContent} + + + { + if (revokeTarget) revokeMutation.mutate(revokeTarget.sid) + }} + onRevokeOthers={() => revokeOthersMutation.mutate()} + /> + + ) +} diff --git a/web/default/src/features/profile/components/passkey-card.tsx b/web/default/src/features/profile/components/passkey-card.tsx index 6ef09e6778fa..21fd59cc33fe 100644 --- a/web/default/src/features/profile/components/passkey-card.tsx +++ b/web/default/src/features/profile/components/passkey-card.tsx @@ -117,6 +117,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { setRestrictedMethod('2fa') await startVerification(register, { + scope: 'passkey.register', preferredMethod: '2fa', title: t('Security verification'), description: t( @@ -151,6 +152,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { setConfirmOpen(false) setRestrictedMethod(required) await startVerification(remove, { + scope: 'passkey.delete', preferredMethod: required, title: t('Security verification'), description: t( diff --git a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx index 55cb2d003c9b..147dcaf0c889 100644 --- a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx +++ b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { Mail, Shield, Send, Link2, Unlink } from 'lucide-react' -import { useEffect, useMemo, useState, useCallback } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { SiGithub, SiWechat, SiLinux } from 'react-icons/si' import { toast } from 'sonner' @@ -27,14 +27,21 @@ import { ConfirmDialog } from '@/components/confirm-dialog' import { StatusBadge } from '@/components/status-badge' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' -import { OAUTH_BIND_STORAGE_KEY } from '@/features/auth/constants' +import { createOAuthFlow } from '@/features/auth/api' +import { + OAUTH_BIND_CALLBACK_MESSAGE, + OAUTH_BIND_RESULT_MESSAGE, +} from '@/features/auth/constants' +import { watchOAuthPopupClosed } from '@/features/auth/lib/oauth-bind-window' +import type { CustomOAuthProviderInfo } from '@/features/auth/types' import { useDialogs } from '@/hooks/use-dialog' import { useStatus } from '@/hooks/use-status' +import { api } from '@/lib/api' import { - handleGitHubOAuth, - handleOIDCOAuth, - handleDiscordOAuth, - handleLinuxDOOAuth, + buildDiscordOAuthUrl, + buildGitHubOAuthUrl, + buildLinuxDOOAuthUrl, + buildOIDCOAuthUrl, } from '@/lib/oauth' import { @@ -58,6 +65,22 @@ interface AccountBindingsTabProps { type DialogKey = 'email' | 'wechat' | 'telegram' +interface PendingOAuthBinding { + provider: string + state: string + popup: Window + stopCloseWatcher: () => void +} + +interface OAuthBindingCallback { + type: typeof OAUTH_BIND_CALLBACK_MESSAGE + provider: string + state: string + code?: string + error?: string + errorDescription?: string +} + export function AccountBindingsTab({ profile, onUpdate, @@ -70,9 +93,20 @@ export function AccountBindingsTab({ null ) const [unbinding, setUnbinding] = useState(false) + const pendingOAuthBinding = useRef(null) + + const clearPendingOAuthBinding = useCallback( + (expected?: PendingOAuthBinding) => { + const pending = pendingOAuthBinding.current + if (!pending || (expected && pending !== expected)) return + pending.stopCloseWatcher() + pendingOAuthBinding.current = null + }, + [] + ) const customProviders = status?.custom_oauth_providers as - | Array<{ id: string; name: string }> + | CustomOAuthProviderInfo[] | undefined const fetchCustomBindings = useCallback(async () => { @@ -115,38 +149,133 @@ export function AccountBindingsTab({ } } - const handleBindCustomOAuth = (provider: { id: string; name: string }) => { - const redirectUrl = `${window.location.origin}/oauth/${provider.id}?bind=true` - window.location.href = `/api/oauth/${provider.id}?redirect=${encodeURIComponent(redirectUrl)}` + const startOAuthBinding = useCallback( + async (provider: string, buildUrl: (state: string) => string) => { + const previous = pendingOAuthBinding.current + if (previous) { + clearPendingOAuthBinding(previous) + if (!previous.popup.closed) previous.popup.close() + } + + const popup = window.open('', '_blank') + if (!popup) { + toast.error(t('OAuth pop-up was blocked')) + return + } + const pending: PendingOAuthBinding = { + provider, + state: '', + popup, + stopCloseWatcher: () => undefined, + } + pending.stopCloseWatcher = watchOAuthPopupClosed(popup, () => + clearPendingOAuthBinding(pending) + ) + pendingOAuthBinding.current = pending + try { + const state = await createOAuthFlow(provider, 'bind') + if (pendingOAuthBinding.current !== pending || popup.closed) return + pending.state = state + popup.location.replace(buildUrl(state)) + } catch { + const isCurrent = pendingOAuthBinding.current === pending + clearPendingOAuthBinding(pending) + popup.close() + if (isCurrent) toast.error(t('Failed to initialize OAuth')) + } + }, + [clearPendingOAuthBinding, t] + ) + + const handleBindCustomOAuth = async (provider: CustomOAuthProviderInfo) => { + await startOAuthBinding(provider.slug, (state) => { + const redirectUri = `${window.location.origin}/oauth/${provider.slug}` + const url = new URL(provider.authorization_endpoint) + url.searchParams.set('client_id', provider.client_id) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('response_type', 'code') + url.searchParams.set('state', state) + if (provider.scopes) url.searchParams.set('scope', provider.scopes) + return url.toString() + }) } useEffect(() => { if (typeof window === 'undefined') return - const handleStorage = (event: StorageEvent) => { - if (event.key !== OAUTH_BIND_STORAGE_KEY || !event.newValue) return + const handleMessage = async (event: MessageEvent) => { + if (event.origin !== window.location.origin) return + const message = event.data as Partial | null + const pending = pendingOAuthBinding.current + if ( + !message || + message.type !== OAUTH_BIND_CALLBACK_MESSAGE || + !pending || + message.provider !== pending.provider || + message.state !== pending.state || + event.source !== pending.popup + ) { + return + } + + clearPendingOAuthBinding(pending) + let success = false + let resultMessage = t('OAuth failed') try { - const payload = JSON.parse(event.newValue) as { - status?: string - provider?: string - timestamp?: number + if (!message.code && !message.error) { + throw new Error(t('Missing code')) } - if (payload?.status === 'success') { + const params: Record = { state: message.state } + if (message.code) params.code = message.code + if (message.error) params.error = message.error + if (message.errorDescription) { + params.error_description = message.errorDescription + } + const response = await api.get(`/api/oauth/${message.provider}`, { + params, + skipBusinessError: true, + }) + success = Boolean(response.data?.success) + resultMessage = response.data?.message || resultMessage + if (success) { + toast.success(t('Binding successful!')) onUpdate() + await fetchCustomBindings() + } else { + toast.error(resultMessage) } - } catch { - // ignore malformed payloads - } - try { - window.localStorage.removeItem(OAUTH_BIND_STORAGE_KEY) - } catch { - // ignore cleanup failure + } catch (error: unknown) { + resultMessage = + (error as { response?: { data?: { message?: string } } }).response + ?.data?.message || + (error instanceof Error ? error.message : resultMessage) + toast.error(resultMessage) } + + pending.popup.postMessage( + { + type: OAUTH_BIND_RESULT_MESSAGE, + provider: message.provider, + state: message.state, + success, + message: resultMessage, + }, + window.location.origin + ) } - window.addEventListener('storage', handleStorage) - return () => window.removeEventListener('storage', handleStorage) - }, [onUpdate]) + window.addEventListener('message', handleMessage) + return () => window.removeEventListener('message', handleMessage) + }, [clearPendingOAuthBinding, fetchCustomBindings, onUpdate, t]) + + useEffect( + () => () => { + const pending = pendingOAuthBinding.current + clearPendingOAuthBinding(pending ?? undefined) + if (pending && !pending.popup.closed) pending.popup.close() + }, + [clearPendingOAuthBinding] + ) // Memoize bindings to prevent unnecessary recalculations const bindings: BindingItem[] = useMemo(() => { @@ -185,8 +314,11 @@ export function AccountBindingsTab({ ), isEnabled: status?.github_oauth || false, onBind: () => { - if (status?.github_client_id) { - handleGitHubOAuth(status.github_client_id) + const clientId = status?.github_client_id + if (clientId) { + void startOAuthBinding('github', (state) => + buildGitHubOAuthUrl(clientId, state) + ) } }, }, @@ -202,8 +334,11 @@ export function AccountBindingsTab({ ), isEnabled: status?.discord_oauth || false, onBind: () => { - if (status?.discord_client_id) { - handleDiscordOAuth(status.discord_client_id) + const clientId = status?.discord_client_id + if (clientId) { + void startOAuthBinding('discord', (state) => + buildDiscordOAuthUrl(clientId, state) + ) } }, }, @@ -219,10 +354,11 @@ export function AccountBindingsTab({ ), isEnabled: status?.oidc_enabled || false, onBind: () => { - if (status?.oidc_authorization_endpoint && status?.oidc_client_id) { - handleOIDCOAuth( - status.oidc_authorization_endpoint, - status.oidc_client_id + const authorizationEndpoint = status?.oidc_authorization_endpoint + const clientId = status?.oidc_client_id + if (authorizationEndpoint && clientId) { + void startOAuthBinding('oidc', (state) => + buildOIDCOAuthUrl(authorizationEndpoint, clientId, state) ) } }, @@ -252,8 +388,11 @@ export function AccountBindingsTab({ ), isEnabled: status?.linuxdo_oauth || false, onBind: () => { - if (status?.linuxdo_client_id) { - handleLinuxDOOAuth(status.linuxdo_client_id) + const clientId = status?.linuxdo_client_id + if (clientId) { + void startOAuthBinding('linuxdo', (state) => + buildLinuxDOOAuthUrl(clientId, state) + ) } }, }, @@ -266,46 +405,51 @@ export function AccountBindingsTab({ return ( <>
- {bindings.map((binding) => ( -
-
-
- -
-
-
-

{binding.label}

- {binding.isBound && ( - - )} + {bindings.map((binding) => { + let actionLabel = t('Bind') + if (binding.isBound && binding.id === 'email') { + actionLabel = t('Change') + } else if (binding.isBound) { + actionLabel = t('Bound') + } + + return ( +
+
+
+ +
+
+
+

{binding.label}

+ {binding.isBound && ( + + )} +
+

+ {binding.value || t('Not bound')} +

-

- {binding.value || t('Not bound')} -

+
- -
- ))} + ) + })}
{/* Custom OAuth Bindings */} @@ -318,7 +462,7 @@ export function AccountBindingsTab({
{customProviders.map((provider) => { const binding = customBindings.find( - (b) => b.provider_id === provider.id + (b) => b.provider_id === String(provider.id) ) const isBound = !!binding return ( diff --git a/web/default/src/features/profile/index.tsx b/web/default/src/features/profile/index.tsx index f65a8888b759..4d5d3aaa8bbe 100644 --- a/web/default/src/features/profile/index.tsx +++ b/web/default/src/features/profile/index.tsx @@ -26,6 +26,7 @@ import { useAuthStore } from '@/stores/auth-store' import { CheckinCalendarCard } from './components/checkin-calendar-card' import { LanguagePreferencesCard } from './components/language-preferences-card' +import { LoginSessionsCard } from './components/login-sessions-card' import { PasskeyCard } from './components/passkey-card' import { ProfileHeader } from './components/profile-header' import { ProfileSecurityCard } from './components/profile-security-card' @@ -67,6 +68,7 @@ export function Profile() { onProfileUpdate={refreshProfile} /> +
diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 45dde3cb7333..c0796abc4b4f 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -657,6 +657,7 @@ "Browse and compare": "Browse and compare", "Browse available models and pricing": "Browse available models and pricing", "Browse rankings by category": "Browse rankings by category", + "Browser": "Browser", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.", "Budget Tokens Ratio": "Budget Tokens Ratio", @@ -1181,6 +1182,7 @@ "Cross-group retry": "Cross-group retry", "Currency": "Currency", "Currency & Display": "Currency & Display", + "Current": "Current", "Current Balance": "Current Balance", "Current Billing": "Current Billing", "Current Cache Size": "Current Cache Size", @@ -1721,6 +1723,7 @@ "Estimated cost": "Estimated cost", "Estimated quota cost": "Estimated quota cost", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.", + "Every other device will lose access immediately. This device will remain signed in.": "Every other device will lose access immediately. This device will remain signed in.", "Everything configured for this group, in one place.": "Everything configured for this group, in one place.", "Exact": "Exact", "Exact Match": "Exact Match", @@ -1848,6 +1851,7 @@ "Failed to load home page content": "Failed to load home page content", "Failed to load image": "Failed to load image", "Failed to load key status": "Failed to load key status", + "Failed to load login sessions": "Failed to load login sessions", "Failed to load logs": "Failed to load logs", "Failed to load Passkey status": "Failed to load Passkey status", "Failed to load playground groups": "Failed to load playground groups", @@ -1884,6 +1888,8 @@ "Failed to send verification email": "Failed to send verification email", "Failed to set tag": "Failed to set tag", "Failed to setup 2FA": "Failed to setup 2FA", + "Failed to sign out other sessions": "Failed to sign out other sessions", + "Failed to sign out session": "Failed to sign out session", "Failed to start {{provider}} login": "Failed to start {{provider}} login", "Failed to start Discord login": "Failed to start Discord login", "Failed to start GitHub login": "Failed to start GitHub login", @@ -1891,7 +1897,9 @@ "Failed to start OIDC login": "Failed to start OIDC login", "Failed to start Passkey login": "Failed to start Passkey login", "Failed to start Passkey registration": "Failed to start Passkey registration", + "Failed to start Telegram binding": "Failed to start Telegram binding", "Failed to start testing all channels": "Failed to start testing all channels", + "Failed to start verification": "Failed to start verification", "Failed to sync prices": "Failed to sync prices", "Failed to sync ratios": "Failed to sync ratios", "Failed to test all channels": "Failed to test all channels", @@ -2360,6 +2368,7 @@ "IP Filter Mode": "IP Filter Mode", "IP Restriction": "IP Restriction", "IP Whitelist (supports CIDR)": "IP Whitelist (supports CIDR)", + "IP: {{ip}} · Method: {{method}}": "IP: {{ip}} · Method: {{method}}", "is less than the configured maximum cache size": "is less than the configured maximum cache size", "is the default price; ": "is the default price; ", "It seems like the page you're looking for": "It seems like the page you're looking for", @@ -2415,6 +2424,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "Language preferences sync across your signed-in devices and affect API error messages.", "Last 24h usage": "Last 24h usage", "Last 30 days uptime": "Last 30 days uptime", + "Last active {{time}} · Expires {{expires}}": "Last active {{time}} · Expires {{expires}}", "Last check time": "Last check time", "Last detected addable models": "Last detected addable models", "Last Login": "Last Login", @@ -2520,8 +2530,10 @@ "Logic": "Logic", "Login": "Login", "Login failed": "Login failed", + "Login flow expired. Please sign in again.": "Login flow expired. Please sign in again.", "Login Info": "Login Info", "Login Method": "Login Method", + "Login sessions": "Login sessions", "Logo": "Logo", "Logo URL": "Logo URL", "Logs": "Logs", @@ -2823,6 +2835,7 @@ "No": "No", "No About Content Set": "No About Content Set", "No Active": "No Active", + "No active login sessions": "No active login sessions", "No active system tasks.": "No active system tasks.", "No additional type-specific settings for this channel type.": "No additional type-specific settings for this channel type.", "No amount options configured. Add amounts below to get started.": "No amount options configured. Add amounts below to get started.", @@ -3026,11 +3039,14 @@ "Number of tokens per unit quota": "Number of tokens per unit quota", "Number of top log probabilities returned per token": "Number of top log probabilities returned per token", "Number of users invited": "Number of users invited", + "OAuth binding timed out. Please try again.": "OAuth binding timed out. Please try again.", + "OAuth binding window is no longer available": "OAuth binding window is no longer available", "OAuth callback URL": "OAuth callback URL", "OAuth Client ID": "OAuth Client ID", "OAuth Client Secret": "OAuth Client Secret", "OAuth failed": "OAuth failed", "OAuth Integrations": "OAuth Integrations", + "OAuth pop-up was blocked": "OAuth pop-up was blocked", "Object Prune Rules": "Object Prune Rules", "Observability": "Observability", "Obtain the API key, merchant ID, and RSA key pair from the Waffo dashboard, and configure the callback URL.": "Obtain the API key, merchant ID, and RSA key pair from the Waffo dashboard, and configure the callback URL.", @@ -3157,6 +3173,7 @@ "Other groups": "Other groups", "Other models": "Other models", "Other nodes": "Other nodes", + "Other sessions signed out": "Other sessions signed out", "Other tokens": "Other tokens", "Other users": "Other users", "Outage": "Outage", @@ -3238,6 +3255,11 @@ "Passkey registration was cancelled": "Passkey registration was cancelled", "Passkey removed successfully": "Passkey removed successfully", "Passkey reset successfully": "Passkey reset successfully", + "Passkey verification failed": "Passkey verification failed", + "Passkey verification is not available in the current state": "Passkey verification is not available in the current state", + "Passkey verification is not supported in this environment": "Passkey verification is not supported in this environment", + "Passkey verification was cancelled": "Passkey verification was cancelled", + "Passkey verification was cancelled or timed out": "Passkey verification was cancelled or timed out", "Passthrough Template": "Passthrough Template", "Password": "Password", "Password / Access Token": "Password / Access Token", @@ -3366,6 +3388,7 @@ "Please enter the authentication code.": "Please enter the authentication code.", "Please enter the URL": "Please enter the URL", "Please enter the verification code": "Please enter the verification code", + "Please enter the verification code or backup code": "Please enter the verification code or backup code", "Please enter your current password": "Please enter your current password", "Please enter your email": "Please enter your email", "Please enter your email first": "Please enter your email first", @@ -3664,6 +3687,7 @@ "Refresh failed": "Refresh failed", "Refresh interval (minutes)": "Refresh interval (minutes)", "Refresh Stats": "Refresh Stats", + "Refresh the list and try again.": "Refresh the list and try again.", "Refreshing...": "Refreshing...", "Refund": "Refund", "Refund Details": "Refund Details", @@ -3676,6 +3700,7 @@ "Register Passkey": "Register Passkey", "Registered a passkey": "Registered a passkey", "Registration Enabled": "Registration Enabled", + "Registration flow expired. Please try again.": "Registration flow expired. Please try again.", "Registry (optional)": "Registry (optional)", "Registry secret": "Registry secret", "Registry username": "Registry username", @@ -3847,9 +3872,12 @@ "Reveal key": "Reveal key", "Revenue": "Revenue", "Review & initialize": "Review & initialize", + "Review and sign out devices currently using your account.": "Review and sign out devices currently using your account.", "Review model rates before scaling traffic": "Review model rates before scaling traffic", "Review your payment details": "Review your payment details", "Review your purchase details before proceeding.": "Review your purchase details before proceeding.", + "Revoke": "Revoke", + "Revoke session?": "Revoke session?", "Rewards will be added directly to your balance": "Rewards will be added directly to your balance", "Rewrite callback URLs to the local server": "Rewrite callback URLs to the local server", "Right to Left": "Right to Left", @@ -4100,6 +4128,7 @@ "Session": "Session", "Session expired!": "Session expired!", "Session expired?": "Session expired?", + "Session signed out": "Session signed out", "Set": "Set", "Set a discount rate for a specific recharge amount threshold.": "Set a discount rate for a specific recharge amount threshold.", "Set a secure password (min. 8 characters)": "Set a secure password (min. 8 characters)", @@ -4164,6 +4193,10 @@ "Sign in required": "Sign in required", "Sign in with Passkey": "Sign in with Passkey", "Sign out": "Sign out", + "Sign out other sessions": "Sign out other sessions", + "Sign out other sessions?": "Sign out other sessions?", + "Sign out others": "Sign out others", + "Sign out this device?": "Sign out this device?", "Sign up": "Sign up", "Signed in": "Signed in", "Signed in successfully!": "Signed in successfully!", @@ -4519,6 +4552,7 @@ "This project must be used in compliance with the": "This project must be used in compliance with the", "This removes {{count}} failed models from this channel. This action cannot be undone.": "This removes {{count}} failed models from this channel. This action cannot be undone.", "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.", + "This session will lose access immediately and must sign in again.": "This session will lose access immediately and must sign in again.", "This site currently has {{count}} models enabled": "This site currently has {{count}} models enabled", "This tier catches any request that did not match earlier tiers.": "This tier catches any request that did not match earlier tiers.", "this token group": "this token group", @@ -4726,9 +4760,11 @@ "Type-Specific Settings": "Type-Specific Settings", "Type:": "Type:", "UI granularity only — data is still aggregated hourly": "UI granularity only — data is still aggregated hourly", + "Unable to build Passkey assertion": "Unable to build Passkey assertion", "Unable to estimate price for this deployment.": "Unable to estimate price for this deployment.", "Unable to generate chat link. Please contact your administrator.": "Unable to generate chat link. Please contact your administrator.", "Unable to load groups": "Unable to load groups", + "Unable to load login sessions": "Unable to load login sessions", "Unable to load rankings": "Unable to load rankings", "Unable to load rankings data": "Unable to load rankings data", "Unable to open chat": "Unable to open chat", @@ -4752,12 +4788,14 @@ "Unit price must be greater than 0": "Unit price must be greater than 0", "Units per USD": "Units per USD", "Unknown": "Unknown", + "Unknown device": "Unknown device", "Unknown version": "Unknown version", "Unlimited": "Unlimited", "Unlimited Quota": "Unlimited Quota", "Unsaved changes": "Unsaved changes", "Unset price": "Unset price", "Unset price models": "Unset price models", + "Unsupported verification method: {{method}}": "Unsupported verification method: {{method}}", "Until": "Until", "Untitled": "Untitled", "Untrusted upstream data:": "Untrusted upstream data:", @@ -4871,6 +4909,7 @@ "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Use one available reset credit for this channel. The reset request is sent only after confirmation.", "Use one available reset credit to refresh the current Codex usage windows.": "Use one available reset credit to refresh the current Codex usage windows.", "Use our unified OpenAI-compatible endpoint in your applications": "Use our unified OpenAI-compatible endpoint in your applications", + "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "Use Passkey or 2FA to confirm your identity before revealing this channel key.", "Use Passkey to sign in without entering your password.": "Use Passkey to sign in without entering your password.", "Use presets or upstream discovery to populate the model list faster.": "Use presets or upstream discovery to populate the model list faster.", "Use secure connection when sending emails": "Use secure connection when sending emails", @@ -4966,12 +5005,15 @@ "Verification code updates every 30 seconds.": "Verification code updates every 30 seconds.", "Verification email sent": "Verification email sent", "Verification failed": "Verification failed", + "Verification flow expired": "Verification flow expired", "Verification is not configured properly": "Verification is not configured properly", + "Verification proof was not returned": "Verification proof was not returned", "Verification required to reveal the saved key.": "Verification required to reveal the saved key.", "Verify": "Verify", "Verify and Sign In": "Verify and Sign In", "Verify routing with Playground or your client": "Verify routing with Playground or your client", "Verify Setup": "Verify Setup", + "Verify to view channel key": "Verify to view channel key", "Verify your database connection": "Verify your database connection", "Verifying credentials and pulling stores from your Pancake account...": "Verifying credentials and pulling stores from your Pancake account...", "Version": "Version", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 996254c8fd62..585dd13d71a7 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -657,6 +657,7 @@ "Browse and compare": "Parcourir et comparer", "Browse available models and pricing": "Parcourir les modèles disponibles et les tarifs", "Browse rankings by category": "Parcourir les classements par catégorie", + "Browser": "Navigateur", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "Jetons budgétaires = jetons max × ratio. Accepte un nombre décimal entre 0,002 et 1. Il est recommandé de rester aligné avec la facturation en amont.", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "Jetons budgétaires = jetons max × ratio. Accepte un nombre décimal entre 0,1 et 1.", "Budget Tokens Ratio": "Ratio de jetons budgétaires", @@ -1181,6 +1182,7 @@ "Cross-group retry": "Nouvelle tentative inter-groupes", "Currency": "Devise", "Currency & Display": "Devise et affichage", + "Current": "Actuelle", "Current Balance": "Solde actuel", "Current Billing": "Facturation actuelle", "Current Cache Size": "Taille actuelle du cache", @@ -1721,6 +1723,7 @@ "Estimated cost": "Coût estimé", "Estimated quota cost": "Coût de quota estimé", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Chaque nom de groupe du tableau tarifaire peut être utilisé à deux endroits : sur un utilisateur (groupe d’utilisateurs, attribué par les admins) et sur un jeton (groupe de jetons, choisi à la création du jeton). Même ensemble de noms, deux rôles différents.", + "Every other device will lose access immediately. This device will remain signed in.": "Tous les autres appareils perdront immédiatement l’accès. Cet appareil restera connecté.", "Everything configured for this group, in one place.": "Toute la configuration de ce groupe, au même endroit.", "Exact": "Exact", "Exact Match": "Correspondance exacte", @@ -1848,6 +1851,7 @@ "Failed to load home page content": "Échec du chargement du contenu de la page d'accueil", "Failed to load image": "Échec du chargement de l'image", "Failed to load key status": "Échec du chargement du statut des clés", + "Failed to load login sessions": "Impossible de charger les sessions de connexion", "Failed to load logs": "Échec du chargement des journaux", "Failed to load Passkey status": "Échec du chargement du statut Passkey", "Failed to load playground groups": "Échec du chargement des groupes du playground", @@ -1884,6 +1888,8 @@ "Failed to send verification email": "Échec de l'envoi de l'e-mail de vérification", "Failed to set tag": "Échec de la définition de l'étiquette", "Failed to setup 2FA": "Échec de la configuration de 2FA", + "Failed to sign out other sessions": "Impossible de déconnecter les autres sessions", + "Failed to sign out session": "Impossible de déconnecter la session", "Failed to start {{provider}} login": "Échec du démarrage de la connexion {{provider}}", "Failed to start Discord login": "Échec du démarrage de la connexion Discord", "Failed to start GitHub login": "Échec du démarrage de la connexion GitHub", @@ -1891,7 +1897,9 @@ "Failed to start OIDC login": "Échec du démarrage de la connexion OIDC", "Failed to start Passkey login": "Impossible de démarrer la connexion Passkey", "Failed to start Passkey registration": "Échec du démarrage de l'enregistrement de la Passkey", + "Failed to start Telegram binding": "Impossible de démarrer l’association Telegram", "Failed to start testing all channels": "Échec du démarrage du test de tous les canaux", + "Failed to start verification": "Impossible de démarrer la vérification", "Failed to sync prices": "Échec de la synchronisation des prix", "Failed to sync ratios": "Échec de la synchronisation des ratios", "Failed to test all channels": "Échec du test de tous les canaux", @@ -2360,6 +2368,7 @@ "IP Filter Mode": "Mode de filtre IP", "IP Restriction": "Restriction IP", "IP Whitelist (supports CIDR)": "Liste blanche IP (supporte CIDR)", + "IP: {{ip}} · Method: {{method}}": "IP : {{ip}} · Méthode : {{method}}", "is less than the configured maximum cache size": "est inférieur à la taille maximale du cache configurée", "is the default price; ": "est le prix par défaut ; ", "It seems like the page you're looking for": "Il semble que la page que vous recherchez", @@ -2415,6 +2424,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "Les préférences de langue se synchronisent sur vos appareils connectés et affectent les messages d'erreur de l'API.", "Last 24h usage": "Utilisation 24h", "Last 30 days uptime": "Disponibilité 30 derniers jours", + "Last active {{time}} · Expires {{expires}}": "Dernière activité {{time}} · Expire le {{expires}}", "Last check time": "Dernière vérification", "Last detected addable models": "Derniers modèles ajoutables détectés", "Last Login": "Dernière connexion", @@ -2520,8 +2530,10 @@ "Logic": "Logique", "Login": "Connexion", "Login failed": "Échec de la connexion", + "Login flow expired. Please sign in again.": "Le processus de connexion a expiré. Veuillez vous reconnecter.", "Login Info": "Informations de connexion", "Login Method": "Méthode de connexion", + "Login sessions": "Sessions de connexion", "Logo": "Logo", "Logo URL": "URL du logo", "Logs": "Journaux", @@ -2823,6 +2835,7 @@ "No": "Non", "No About Content Set": "Aucun contenu « À propos » défini", "No Active": "Aucun actif", + "No active login sessions": "Aucune session de connexion active", "No active system tasks.": "Aucune tâche système active.", "No additional type-specific settings for this channel type.": "Aucun paramètre supplémentaire spécifique au type pour ce type de canal.", "No amount options configured. Add amounts below to get started.": "Aucune option de montant configurée. Ajoutez des montants ci-dessous pour commencer.", @@ -3026,11 +3039,14 @@ "Number of tokens per unit quota": "Nombre de jetons par unité de quota", "Number of top log probabilities returned per token": "Nombre de log-probabilités retournées par jeton", "Number of users invited": "Nombre d'utilisateurs invités", + "OAuth binding timed out. Please try again.": "La liaison OAuth a expiré. Veuillez réessayer.", + "OAuth binding window is no longer available": "La fenêtre d’association OAuth n’est plus disponible", "OAuth callback URL": "URL de rappel OAuth", "OAuth Client ID": "ID client OAuth", "OAuth Client Secret": "Secret client OAuth", "OAuth failed": "Échec de l'OAuth", "OAuth Integrations": "Intégrations OAuth", + "OAuth pop-up was blocked": "La fenêtre contextuelle OAuth a été bloquée", "Object Prune Rules": "Règles de nettoyage d'objets", "Observability": "Observabilité", "Obtain the API key, merchant ID, and RSA key pair from the Waffo dashboard, and configure the callback URL.": "Obtenez la clé API, l'ID du commerçant et la paire de clés RSA depuis le tableau de bord Waffo, et configurez l'URL de rappel.", @@ -3157,6 +3173,7 @@ "Other groups": "Autres groupes", "Other models": "Autres modèles", "Other nodes": "Autres nœuds", + "Other sessions signed out": "Les autres sessions ont été déconnectées", "Other tokens": "Autres jetons", "Other users": "Autres utilisateurs", "Outage": "Interruption", @@ -3238,6 +3255,11 @@ "Passkey registration was cancelled": "L'enregistrement de la Passkey a été annulé", "Passkey removed successfully": "Passkey supprimée avec succès", "Passkey reset successfully": "Passkey réinitialisée avec succès", + "Passkey verification failed": "La vérification par Passkey a échoué", + "Passkey verification is not available in the current state": "La vérification par Passkey n’est pas disponible dans l’état actuel", + "Passkey verification is not supported in this environment": "La vérification par Passkey n’est pas prise en charge dans cet environnement", + "Passkey verification was cancelled": "La vérification par Passkey a été annulée", + "Passkey verification was cancelled or timed out": "La vérification par Passkey a été annulée ou a expiré", "Passthrough Template": "Modèle de transmission", "Password": "Mot de passe", "Password / Access Token": "Mot de passe / Jeton d'accès", @@ -3366,6 +3388,7 @@ "Please enter the authentication code.": "Veuillez saisir le code d'authentification.", "Please enter the URL": "Veuillez saisir l'URL", "Please enter the verification code": "Veuillez saisir le code de vérification", + "Please enter the verification code or backup code": "Saisissez le code de vérification ou un code de secours", "Please enter your current password": "Veuillez saisir votre mot de passe actuel", "Please enter your email": "Veuillez saisir votre adresse e-mail", "Please enter your email first": "Veuillez saisir votre email en premier", @@ -3664,6 +3687,7 @@ "Refresh failed": "Échec de l'actualisation", "Refresh interval (minutes)": "Intervalle d'actualisation (minutes)", "Refresh Stats": "Actualiser les statistiques", + "Refresh the list and try again.": "Actualisez la liste et réessayez.", "Refreshing...": "Actualisation...", "Refund": "Remboursement", "Refund Details": "Détails du remboursement", @@ -3676,6 +3700,7 @@ "Register Passkey": "Enregistrer un Passkey", "Registered a passkey": "Passkey enregistré", "Registration Enabled": "Inscription activée", + "Registration flow expired. Please try again.": "Le processus d’inscription a expiré. Veuillez réessayer.", "Registry (optional)": "Registre (optionnel)", "Registry secret": "Secret du registre", "Registry username": "Nom d'utilisateur du registre", @@ -3847,9 +3872,12 @@ "Reveal key": "Révéler la clé", "Revenue": "Revenu", "Review & initialize": "Vérifier et initialiser", + "Review and sign out devices currently using your account.": "Consultez et déconnectez les appareils qui utilisent actuellement votre compte.", "Review model rates before scaling traffic": "Consulter les tarifs des modèles avant d'augmenter le trafic", "Review your payment details": "Vérifier vos détails de paiement", "Review your purchase details before proceeding.": "Vérifiez les détails de votre achat avant de continuer.", + "Revoke": "Révoquer", + "Revoke session?": "Révoquer cette session ?", "Rewards will be added directly to your balance": "Les récompenses seront ajoutées directement à votre solde", "Rewrite callback URLs to the local server": "Réécrire les URLs de callback vers le serveur local", "Right to Left": "De droite à gauche", @@ -4100,6 +4128,7 @@ "Session": "Session", "Session expired!": "Session expirée !", "Session expired?": "Session expirée ?", + "Session signed out": "Session déconnectée", "Set": "Définir", "Set a discount rate for a specific recharge amount threshold.": "Définir un taux de réduction pour un seuil de montant de recharge spécifique.", "Set a secure password (min. 8 characters)": "Définir un mot de passe sécurisé (min. 8 caractères)", @@ -4164,6 +4193,10 @@ "Sign in required": "Connexion requise", "Sign in with Passkey": "Se connecter avec Passkey", "Sign out": "Se déconnecter", + "Sign out other sessions": "Déconnecter les autres sessions", + "Sign out other sessions?": "Déconnecter les autres sessions ?", + "Sign out others": "Déconnecter les autres", + "Sign out this device?": "Déconnecter cet appareil ?", "Sign up": "S'inscrire", "Signed in": "Connecté", "Signed in successfully!": "Connecté avec succès !", @@ -4519,6 +4552,7 @@ "This project must be used in compliance with the": "Ce projet doit être utilisé conformément aux", "This removes {{count}} failed models from this channel. This action cannot be undone.": "Cela supprime {{count}} modèles en échec de ce canal. Cette action est irréversible.", "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Cette route découvre les modèles OpenAI en amont et ne peut être ni divisée ni associée par des règles de modèles clients.", + "This session will lose access immediately and must sign in again.": "Cette session perdra immédiatement l’accès et devra se reconnecter.", "This site currently has {{count}} models enabled": "Ce site compte actuellement {{count}} modèles activés", "This tier catches any request that did not match earlier tiers.": "Ce palier récupère toute requête qui ne correspond à aucun palier précédent.", "this token group": "ce groupe de jetons", @@ -4726,9 +4760,11 @@ "Type-Specific Settings": "Paramètres spécifiques au type", "Type:": "Type :", "UI granularity only — data is still aggregated hourly": "Granularité de l'interface uniquement — les données sont toujours agrégées par heure", + "Unable to build Passkey assertion": "Impossible de créer l’assertion Passkey", "Unable to estimate price for this deployment.": "Impossible d'estimer le prix pour ce déploiement.", "Unable to generate chat link. Please contact your administrator.": "Impossible de générer le lien de discussion. Veuillez contacter votre administrateur.", "Unable to load groups": "Impossible de charger les groupes", + "Unable to load login sessions": "Impossible de charger les sessions de connexion", "Unable to load rankings": "Impossible de charger les classements", "Unable to load rankings data": "Impossible de charger les données des classements", "Unable to open chat": "Impossible d'ouvrir la discussion", @@ -4752,12 +4788,14 @@ "Unit price must be greater than 0": "Le prix unitaire doit être supérieur à 0", "Units per USD": "Unités par USD", "Unknown": "Inconnu", + "Unknown device": "Appareil inconnu", "Unknown version": "Version inconnue", "Unlimited": "Illimité", "Unlimited Quota": "Quota illimité", "Unsaved changes": "Modifications non enregistrées", "Unset price": "Prix non défini", "Unset price models": "Modèles sans prix", + "Unsupported verification method: {{method}}": "Méthode de vérification non prise en charge : {{method}}", "Until": "Jusqu'au", "Untitled": "Sans titre", "Untrusted upstream data:": "Données amont non fiables :", @@ -4871,6 +4909,7 @@ "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Utilise un crédit de réinitialisation disponible pour ce canal. La demande n’est envoyée qu’après confirmation.", "Use one available reset credit to refresh the current Codex usage windows.": "Utilise un crédit de réinitialisation disponible pour actualiser les fenêtres d’utilisation Codex actuelles.", "Use our unified OpenAI-compatible endpoint in your applications": "Utilisez notre point de terminaison unifié compatible OpenAI dans vos applications", + "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "Utilisez une Passkey ou la 2FA pour confirmer votre identité avant d’afficher cette clé de canal.", "Use Passkey to sign in without entering your password.": "Utilisez une clé d'accès (Passkey) pour vous connecter sans saisir votre mot de passe.", "Use presets or upstream discovery to populate the model list faster.": "Utilisez des préréglages ou la découverte en amont pour remplir plus vite la liste des modèles.", "Use secure connection when sending emails": "Utiliser une connexion sécurisée lors de l'envoi d'e-mails", @@ -4966,12 +5005,15 @@ "Verification code updates every 30 seconds.": "Le code de vérification se met à jour toutes les 30 secondes.", "Verification email sent": "Email de vérification envoyé", "Verification failed": "Échec de la vérification", + "Verification flow expired": "Le processus de vérification a expiré", "Verification is not configured properly": "La vérification n'est pas configurée correctement", + "Verification proof was not returned": "La preuve de vérification n’a pas été renvoyée", "Verification required to reveal the saved key.": "Vérification requise pour révéler la clé enregistrée.", "Verify": "Vérifier", "Verify and Sign In": "Vérifier et se connecter", "Verify routing with Playground or your client": "Vérifiez le routage avec Playground ou votre client", "Verify Setup": "Vérifier la configuration", + "Verify to view channel key": "Vérifier pour afficher la clé du canal", "Verify your database connection": "Vérifiez votre connexion à la base de données", "Verifying credentials and pulling stores from your Pancake account...": "Vérification des identifiants et récupération des boutiques depuis votre compte Pancake...", "Version": "Version", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 0449ac251d83..bd564e954186 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -657,6 +657,7 @@ "Browse and compare": "参照と比較", "Browse available models and pricing": "利用可能なモデルと料金を確認", "Browse rankings by category": "カテゴリ別にランキングを表示", + "Browser": "ブラウザー", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "予算トークン = 最大トークン × 比率。0.002から1までの小数を指定できます。アップストリームの請求と一致させることを推奨します。", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "予算トークン = 最大トークン × 比率。0.1から1までの小数を指定できます。", "Budget Tokens Ratio": "予算トークン比率", @@ -1181,6 +1182,7 @@ "Cross-group retry": "グループ横断リトライ", "Currency": "通貨", "Currency & Display": "通貨と表示", + "Current": "現在", "Current Balance": "現在の残高", "Current Billing": "現在の請求", "Current Cache Size": "現在のキャッシュサイズ", @@ -1721,6 +1723,7 @@ "Estimated cost": "推定コスト", "Estimated quota cost": "想定クォートコスト", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "料金表の各グループ名は2つの場所で使えます。ユーザー側(ユーザーグループ、管理者が割り当て)とトークン側(トークングループ、トークン作成時に選択)です。同じ名前プールで、役割は2つです。", + "Every other device will lose access immediately. This device will remain signed in.": "他のすべてのデバイスは直ちにアクセスできなくなります。このデバイスはログイン状態を維持します。", "Everything configured for this group, in one place.": "このグループのすべての設定を一か所で確認できます。", "Exact": "完全一致", "Exact Match": "完全一致", @@ -1848,6 +1851,7 @@ "Failed to load home page content": "ホームページの内容の読み込みに失敗しました", "Failed to load image": "画像の読み込みに失敗しました", "Failed to load key status": "キー状態の読み込みに失敗しました", + "Failed to load login sessions": "ログインセッションの読み込みに失敗しました", "Failed to load logs": "ログの読み込みに失敗しました", "Failed to load Passkey status": "Passkeyのステータスの読み込みに失敗しました", "Failed to load playground groups": "プレイグラウンドのグループ読み込みに失敗しました", @@ -1884,6 +1888,8 @@ "Failed to send verification email": "確認メールの送信に失敗しました", "Failed to set tag": "タグの設定に失敗しました", "Failed to setup 2FA": "2FA の設定に失敗しました", + "Failed to sign out other sessions": "他のセッションのサインアウトに失敗しました", + "Failed to sign out session": "セッションのサインアウトに失敗しました", "Failed to start {{provider}} login": "{{provider}} ログインの開始に失敗しました", "Failed to start Discord login": "Discordログインの開始に失敗しました", "Failed to start GitHub login": "GitHubログインの開始に失敗しました", @@ -1891,7 +1897,9 @@ "Failed to start OIDC login": "OIDCログインの開始に失敗しました", "Failed to start Passkey login": "Passkeyログインの開始に失敗しました", "Failed to start Passkey registration": "パスキー登録の開始に失敗しました", + "Failed to start Telegram binding": "Telegram 連携の開始に失敗しました", "Failed to start testing all channels": "すべてのチャネルのテストを開始できませんでした", + "Failed to start verification": "認証の開始に失敗しました", "Failed to sync prices": "価格の同期に失敗しました", "Failed to sync ratios": "比率の同期に失敗しました", "Failed to test all channels": "すべてのチャネルのテストに失敗しました", @@ -2360,6 +2368,7 @@ "IP Filter Mode": "IP フィルターモード", "IP Restriction": "IP制限", "IP Whitelist (supports CIDR)": "IP ホワイトリスト(CIDR対応)", + "IP: {{ip}} · Method: {{method}}": "IP: {{ip}} · 方法: {{method}}", "is less than the configured maximum cache size": "設定された最大キャッシュサイズより小さい", "is the default price; ": "はデフォルト価格です; ", "It seems like the page you're looking for": "お探しのページは", @@ -2415,6 +2424,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "言語設定はログイン中のすべてのデバイスで同期され、API のエラーメッセージ言語にも反映されます。", "Last 24h usage": "直近24時間の使用量", "Last 30 days uptime": "直近 30 日の稼働率", + "Last active {{time}} · Expires {{expires}}": "最終利用 {{time}} · 有効期限 {{expires}}", "Last check time": "最終チェック時刻", "Last detected addable models": "最後に検出された追加可能モデル", "Last Login": "最終ログイン", @@ -2520,8 +2530,10 @@ "Logic": "ロジック", "Login": "ログイン", "Login failed": "ログインに失敗しました", + "Login flow expired. Please sign in again.": "ログイン手続きの有効期限が切れました。もう一度サインインしてください。", "Login Info": "ログイン情報", "Login Method": "ログイン方法", + "Login sessions": "ログインセッション", "Logo": "ロゴ", "Logo URL": "ロゴURL", "Logs": "ログ", @@ -2823,6 +2835,7 @@ "No": "いいえ", "No About Content Set": "概要コンテンツが設定されていません", "No Active": "アクティブなし", + "No active login sessions": "有効なログインセッションはありません", "No active system tasks.": "進行中のシステムタスクはありません。", "No additional type-specific settings for this channel type.": "このチャネルタイプには、追加のタイプ固有の設定はありません。", "No amount options configured. Add amounts below to get started.": "金額オプションは設定されていません。開始するには、以下の金額を追加してください。", @@ -3026,11 +3039,14 @@ "Number of tokens per unit quota": "単位クォータあたりのトークン数", "Number of top log probabilities returned per token": "トークンごとに返される上位対数確率の数", "Number of users invited": "招待されたユーザー数", + "OAuth binding timed out. Please try again.": "OAuth 連携がタイムアウトしました。もう一度お試しください。", + "OAuth binding window is no longer available": "OAuth 連携ウィンドウは利用できなくなりました", "OAuth callback URL": "OAuth コールバック URL", "OAuth Client ID": "OAuthクライアントID", "OAuth Client Secret": "OAuthクライアントシークレット", "OAuth failed": "OAuth に失敗しました", "OAuth Integrations": "OAuth連携", + "OAuth pop-up was blocked": "OAuth ポップアップがブロックされました", "Object Prune Rules": "オブジェクト削除ルール", "Observability": "可観測性", "Obtain the API key, merchant ID, and RSA key pair from the Waffo dashboard, and configure the callback URL.": "Waffoダッシュボードから APIキー、マーチャントID、RSAキーペアを取得し、コールバックURLを設定してください。", @@ -3157,6 +3173,7 @@ "Other groups": "その他のグループ", "Other models": "その他のモデル", "Other nodes": "その他のノード", + "Other sessions signed out": "他のセッションからサインアウトしました", "Other tokens": "その他のトークン", "Other users": "その他のユーザー", "Outage": "ダウンタイム", @@ -3238,6 +3255,11 @@ "Passkey registration was cancelled": "パスキーの登録がキャンセルされました", "Passkey removed successfully": "パスキーが正常に削除されました", "Passkey reset successfully": "パスキーが正常にリセットされました", + "Passkey verification failed": "Passkey 認証に失敗しました", + "Passkey verification is not available in the current state": "現在の状態では Passkey 認証を利用できません", + "Passkey verification is not supported in this environment": "この環境では Passkey 認証をサポートしていません", + "Passkey verification was cancelled": "Passkey 認証がキャンセルされました", + "Passkey verification was cancelled or timed out": "Passkey 認証がキャンセルされたか、タイムアウトしました", "Passthrough Template": "透過テンプレート", "Password": "パスワード", "Password / Access Token": "パスワード / アクセストークン", @@ -3366,6 +3388,7 @@ "Please enter the authentication code.": "認証コードを入力してください。", "Please enter the URL": "URLを入力してください", "Please enter the verification code": "確認コードを入力してください", + "Please enter the verification code or backup code": "認証コードまたはバックアップコードを入力してください", "Please enter your current password": "現在のパスワードを入力してください", "Please enter your email": "メールアドレスを入力してください", "Please enter your email first": "まずメールアドレスを入力してください", @@ -3664,6 +3687,7 @@ "Refresh failed": "更新に失敗しました", "Refresh interval (minutes)": "更新間隔 (分)", "Refresh Stats": "統計を更新", + "Refresh the list and try again.": "一覧を更新して、もう一度お試しください。", "Refreshing...": "更新中...", "Refund": "返金", "Refund Details": "返金詳細", @@ -3676,6 +3700,7 @@ "Register Passkey": "Passkeyの登録", "Registered a passkey": "パスキーを登録しました", "Registration Enabled": "登録が有効", + "Registration flow expired. Please try again.": "登録手続きの有効期限が切れました。もう一度お試しください。", "Registry (optional)": "レジストリ (オプション)", "Registry secret": "レジストリ シークレット", "Registry username": "レジストリ ユーザー名", @@ -3847,9 +3872,12 @@ "Reveal key": "キーを表示", "Revenue": "収益", "Review & initialize": "確認して初期化", + "Review and sign out devices currently using your account.": "現在アカウントを使用しているデバイスを確認し、サインアウトできます。", "Review model rates before scaling traffic": "トラフィック拡大前にモデル料金を確認", "Review your payment details": "支払い詳細を確認", "Review your purchase details before proceeding.": "続行前に購入詳細を確認してください。", + "Revoke": "取り消す", + "Revoke session?": "このセッションを取り消しますか?", "Rewards will be added directly to your balance": "報酬は直接残高に追加されます", "Rewrite callback URLs to the local server": "コールバック URL をローカルサーバーに書き換え", "Right to Left": "右から左", @@ -4100,6 +4128,7 @@ "Session": "セッション", "Session expired!": "セッションが期限切れです!", "Session expired?": "セッションが期限切れになりましたか?", + "Session signed out": "セッションからサインアウトしました", "Set": "設定", "Set a discount rate for a specific recharge amount threshold.": "特定のチャージ金額のしきい値に対して割引率を設定します。", "Set a secure password (min. 8 characters)": "安全なパスワードを設定してください (最低8文字)", @@ -4164,6 +4193,10 @@ "Sign in required": "ログインが必要です", "Sign in with Passkey": "Passkeyでログイン", "Sign out": "ログアウト", + "Sign out other sessions": "他のセッションをサインアウト", + "Sign out other sessions?": "他のセッションからサインアウトしますか?", + "Sign out others": "他をサインアウト", + "Sign out this device?": "このデバイスからサインアウトしますか?", "Sign up": "サインアップ", "Signed in": "サインインしました", "Signed in successfully!": "サインインに成功しました!", @@ -4519,6 +4552,7 @@ "This project must be used in compliance with the": "このプロジェクトは、以下を遵守して使用する必要があります", "This removes {{count}} failed models from this channel. This action cannot be undone.": "この操作はこのチャンネルから失敗した {{count}} 個のモデルを削除します。元に戻せません。", "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "このルートはアップストリームの OpenAI モデルを検出するためのもので、分割やクライアントモデルルールによる照合はできません。", + "This session will lose access immediately and must sign in again.": "このセッションは直ちにアクセスできなくなり、再度サインインが必要になります。", "This site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です", "This tier catches any request that did not match earlier tiers.": "この段階は、前の段階に一致しなかったすべてのリクエストを受け取ります。", "this token group": "このトークングループ", @@ -4726,9 +4760,11 @@ "Type-Specific Settings": "タイプ固有の設定", "Type:": "タイプ:", "UI granularity only — data is still aggregated hourly": "UIの粒度のみ — データは引き続き時間単位で集計されます", + "Unable to build Passkey assertion": "Passkey アサーションを作成できません", "Unable to estimate price for this deployment.": "このデプロイメントの価格を推定できません。", "Unable to generate chat link. Please contact your administrator.": "チャットリンクを生成できません。管理者にご連絡ください。", "Unable to load groups": "グループをロードできません", + "Unable to load login sessions": "ログインセッションを読み込めません", "Unable to load rankings": "ランキングを読み込めません", "Unable to load rankings data": "ランキングデータを読み込めません", "Unable to open chat": "チャットを開けません", @@ -4752,12 +4788,14 @@ "Unit price must be greater than 0": "単価は 0 より大きい必要があります", "Units per USD": "USDあたりのユニット数", "Unknown": "不明", + "Unknown device": "不明なデバイス", "Unknown version": "不明なバージョン", "Unlimited": "無制限", "Unlimited Quota": "無制限のクォータ", "Unsaved changes": "未保存の変更", "Unset price": "価格未設定", "Unset price models": "価格が未設定のモデル", + "Unsupported verification method: {{method}}": "サポートされていない認証方法です: {{method}}", "Until": "まで", "Untitled": "無題", "Untrusted upstream data:": "信頼されていないアップストリームデータ:", @@ -4871,6 +4909,7 @@ "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "このチャンネルで利用可能なリセット回数を1回使用します。確認後にのみリセット要求を送信します。", "Use one available reset credit to refresh the current Codex usage windows.": "利用可能なリセット回数を1回使用して、現在の Codex 使用量ウィンドウを更新します。", "Use our unified OpenAI-compatible endpoint in your applications": "アプリケーションでOpenAI互換の統一エンドポイントを使用", + "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "チャネルキーを表示する前に、Passkey または 2FA で本人確認を行ってください。", "Use Passkey to sign in without entering your password.": "パスワードを入力せずにサインインするには、パスキーを使用してください。", "Use presets or upstream discovery to populate the model list faster.": "プリセットまたは上流検出を使ってモデルリストをすばやく入力します。", "Use secure connection when sending emails": "メール送信時に安全な接続を使用する", @@ -4966,12 +5005,15 @@ "Verification code updates every 30 seconds.": "認証コードは 30 秒ごとに更新されます。", "Verification email sent": "認証メールを送信しました", "Verification failed": "認証に失敗しました", + "Verification flow expired": "認証手続きの有効期限が切れました", "Verification is not configured properly": "認証が正しく設定されていません", + "Verification proof was not returned": "認証証明が返されませんでした", "Verification required to reveal the saved key.": "保存されたキーを表示するには、認証が必要です。", "Verify": "認証", "Verify and Sign In": "確認してサインイン", "Verify routing with Playground or your client": "Playground またはクライアントでルーティングを確認", "Verify Setup": "設定を確認", + "Verify to view channel key": "認証してチャネルキーを表示", "Verify your database connection": "データベース接続を確認", "Verifying credentials and pulling stores from your Pancake account...": "認証情報を検証し、Pancake アカウントからストアを取得しています...", "Version": "バージョン", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index aa5051c00c28..0fde4d335d19 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -657,6 +657,7 @@ "Browse and compare": "Просмотр и сравнение", "Browse available models and pricing": "Просмотрите доступные модели и цены", "Browse rankings by category": "Просмотр рейтингов по категориям", + "Browser": "Браузер", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "Бюджетные токены = макс. токены × соотношение. Принимает десятичное число от 0.002 до 1. Рекомендуется поддерживать в соответствии с биллингом вышестоящего провайдера.", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "Бюджетные токены = макс. токены × соотношение. Принимает десятичное число от 0.1 до 1.", "Budget Tokens Ratio": "Соотношение бюджетных токенов", @@ -1181,6 +1182,7 @@ "Cross-group retry": "Повтор между группами", "Currency": "Валюта", "Currency & Display": "Валюта и отображение", + "Current": "Текущий", "Current Balance": "Текущий баланс", "Current Billing": "Текущие счета", "Current Cache Size": "Текущий размер кэша", @@ -1721,6 +1723,7 @@ "Estimated cost": "Примерная стоимость", "Estimated quota cost": "Ориентир стоимости квоты", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Каждое имя группы из таблицы тарифов используется в двух местах: у пользователя (группа пользователя, назначается администратором) и у токена (группа токена, выбирается при создании). Один набор имён — две разные роли.", + "Every other device will lose access immediately. This device will remain signed in.": "Все остальные устройства немедленно потеряют доступ. Это устройство останется в системе.", "Everything configured for this group, in one place.": "Все настройки этой группы в одном месте.", "Exact": "Точное", "Exact Match": "Точное совпадение", @@ -1848,6 +1851,7 @@ "Failed to load home page content": "Не удалось загрузить содержимое главной страницы", "Failed to load image": "Не удалось загрузить изображение", "Failed to load key status": "Не удалось загрузить статус ключей", + "Failed to load login sessions": "Не удалось загрузить сеансы входа", "Failed to load logs": "Не удалось загрузить логи", "Failed to load Passkey status": "Не удалось загрузить статус Passkey", "Failed to load playground groups": "Не удалось загрузить группы площадки", @@ -1884,6 +1888,8 @@ "Failed to send verification email": "Не удалось отправить письмо с подтверждением", "Failed to set tag": "Не удалось установить тег", "Failed to setup 2FA": "Не удалось настроить 2FA", + "Failed to sign out other sessions": "Не удалось завершить другие сеансы", + "Failed to sign out session": "Не удалось завершить сеанс", "Failed to start {{provider}} login": "Не удалось начать вход через {{provider}}", "Failed to start Discord login": "Не удалось начать вход через Discord", "Failed to start GitHub login": "Не удалось начать вход через GitHub", @@ -1891,7 +1897,9 @@ "Failed to start OIDC login": "Не удалось начать вход через OIDC", "Failed to start Passkey login": "Не удалось начать вход с Passkey", "Failed to start Passkey registration": "Не удалось начать регистрацию Passkey", + "Failed to start Telegram binding": "Не удалось начать привязку Telegram", "Failed to start testing all channels": "Не удалось начать тестирование всех каналов", + "Failed to start verification": "Не удалось начать проверку", "Failed to sync prices": "Не удалось синхронизировать цены", "Failed to sync ratios": "Не удалось синхронизировать коэффициенты", "Failed to test all channels": "Не удалось протестировать все каналы", @@ -2360,6 +2368,7 @@ "IP Filter Mode": "Режим фильтрации IP", "IP Restriction": "Ограничение IP", "IP Whitelist (supports CIDR)": "Белый список IP (поддерживает CIDR)", + "IP: {{ip}} · Method: {{method}}": "IP: {{ip}} · Способ: {{method}}", "is less than the configured maximum cache size": "меньше настроенного максимального размера кэша", "is the default price; ": "— цена по умолчанию; ", "It seems like the page you're looking for": "Похоже, страница, которую вы ищете", @@ -2415,6 +2424,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "Языковые настройки синхронизируются на всех ваших устройствах после входа и влияют на язык сообщений об ошибках API.", "Last 24h usage": "Расход за 24ч", "Last 30 days uptime": "Доступность за 30 дней", + "Last active {{time}} · Expires {{expires}}": "Последняя активность: {{time}} · Истекает: {{expires}}", "Last check time": "Время последней проверки", "Last detected addable models": "Последние обнаруженные модели для добавления", "Last Login": "Последний вход", @@ -2520,8 +2530,10 @@ "Logic": "Логика", "Login": "Вход", "Login failed": "Ошибка входа", + "Login flow expired. Please sign in again.": "Процесс входа истёк. Войдите снова.", "Login Info": "Информация о входе", "Login Method": "Способ входа", + "Login sessions": "Сеансы входа", "Logo": "Логотип", "Logo URL": "URL логотипа", "Logs": "Журналы", @@ -2823,6 +2835,7 @@ "No": "Нет", "No About Content Set": "Содержимое раздела \"О нас\" не установлено", "No Active": "Нет активных", + "No active login sessions": "Нет активных сеансов входа", "No active system tasks.": "Нет активных системных задач.", "No additional type-specific settings for this channel type.": "Нет дополнительных настроек, специфичных для этого типа канала.", "No amount options configured. Add amounts below to get started.": "Не настроены параметры суммы. Добавьте суммы ниже, чтобы начать.", @@ -3026,11 +3039,14 @@ "Number of tokens per unit quota": "Количество токенов на единицу квоты", "Number of top log probabilities returned per token": "Количество top-вероятностей на токен", "Number of users invited": "Количество приглашенных пользователей", + "OAuth binding timed out. Please try again.": "Время ожидания привязки OAuth истекло. Повторите попытку.", + "OAuth binding window is no longer available": "Окно привязки OAuth больше недоступно", "OAuth callback URL": "URL обратного вызова OAuth", "OAuth Client ID": "Идентификатор клиента OAuth", "OAuth Client Secret": "OAuth Client Secret", "OAuth failed": "OAuth не удался", "OAuth Integrations": "Интеграции OAuth", + "OAuth pop-up was blocked": "Всплывающее окно OAuth заблокировано", "Object Prune Rules": "Правила очистки объектов", "Observability": "Наблюдаемость", "Obtain the API key, merchant ID, and RSA key pair from the Waffo dashboard, and configure the callback URL.": "Получите API-ключ, ID мерчанта и пару RSA-ключей в панели управления Waffo и настройте URL обратного вызова.", @@ -3157,6 +3173,7 @@ "Other groups": "Другие группы", "Other models": "Другие модели", "Other nodes": "Другие узлы", + "Other sessions signed out": "Другие сеансы завершены", "Other tokens": "Другие токены", "Other users": "Другие пользователи", "Outage": "Простой", @@ -3238,6 +3255,11 @@ "Passkey registration was cancelled": "Регистрация Passkey была отменена", "Passkey removed successfully": "Passkey успешно удалён", "Passkey reset successfully": "Passkey успешно сброшен", + "Passkey verification failed": "Проверка Passkey не удалась", + "Passkey verification is not available in the current state": "Проверка Passkey недоступна в текущем состоянии", + "Passkey verification is not supported in this environment": "Проверка Passkey не поддерживается в этой среде", + "Passkey verification was cancelled": "Проверка Passkey отменена", + "Passkey verification was cancelled or timed out": "Проверка Passkey отменена или истекло время ожидания", "Passthrough Template": "Шаблон сквозной передачи", "Password": "Пароль", "Password / Access Token": "Пароль / Токен доступа", @@ -3366,6 +3388,7 @@ "Please enter the authentication code.": "Пожалуйста, введите код аутентификации.", "Please enter the URL": "Пожалуйста, введите URL", "Please enter the verification code": "Введите код подтверждения", + "Please enter the verification code or backup code": "Введите код подтверждения или резервный код", "Please enter your current password": "Пожалуйста, введите текущий пароль", "Please enter your email": "Введите адрес электронной почты", "Please enter your email first": "Пожалуйста, сначала введите ваш email", @@ -3664,6 +3687,7 @@ "Refresh failed": "Ошибка обновления", "Refresh interval (minutes)": "Интервал обновления (минуты)", "Refresh Stats": "Обновить статистику", + "Refresh the list and try again.": "Обновите список и повторите попытку.", "Refreshing...": "Обновление...", "Refund": "Возврат", "Refund Details": "Детали возврата", @@ -3676,6 +3700,7 @@ "Register Passkey": "Регистрация Passkey", "Registered a passkey": "Ключ доступа зарегистрирован", "Registration Enabled": "Регистрация включена", + "Registration flow expired. Please try again.": "Процесс регистрации истёк. Повторите попытку.", "Registry (optional)": "Реестр (необязательно)", "Registry secret": "Секрет реестра", "Registry username": "Имя пользователя реестра", @@ -3847,9 +3872,12 @@ "Reveal key": "Показать ключ", "Revenue": "Доход", "Review & initialize": "Проверить и инициализировать", + "Review and sign out devices currently using your account.": "Просмотрите и отключите устройства, которые сейчас используют вашу учётную запись.", "Review model rates before scaling traffic": "Проверьте тарифы моделей перед масштабированием трафика", "Review your payment details": "Проверьте свои платежные данные", "Review your purchase details before proceeding.": "Просмотрите детали покупки перед продолжением.", + "Revoke": "Отозвать", + "Revoke session?": "Отозвать сеанс?", "Rewards will be added directly to your balance": "Награды будут добавлены напрямую в ваш баланс", "Rewrite callback URLs to the local server": "Перезаписывать URL обратных вызовов на локальный сервер", "Right to Left": "Справа налево", @@ -4100,6 +4128,7 @@ "Session": "Сессия", "Session expired!": "Сессия истекла!", "Session expired?": "Сессия истекла?", + "Session signed out": "Сеанс завершён", "Set": "Установить", "Set a discount rate for a specific recharge amount threshold.": "Установите ставку скидки для определенного порога суммы пополнения.", "Set a secure password (min. 8 characters)": "Установите надежный пароль (минимум 8 символов)", @@ -4164,6 +4193,10 @@ "Sign in required": "Требуется вход", "Sign in with Passkey": "Войти с Passkey", "Sign out": "Выйти", + "Sign out other sessions": "Завершить другие сеансы", + "Sign out other sessions?": "Завершить другие сеансы?", + "Sign out others": "Завершить другие", + "Sign out this device?": "Выйти на этом устройстве?", "Sign up": "Регистрация", "Signed in": "Вход выполнен", "Signed in successfully!": "Успешный вход!", @@ -4519,6 +4552,7 @@ "This project must be used in compliance with the": "Этот проект должен использоваться в соответствии с", "This removes {{count}} failed models from this channel. This action cannot be undone.": "Это удалит {{count}} неуспешных моделей из этого канала. Действие необратимо.", "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Этот маршрут обнаруживает модели OpenAI вышестоящего сервиса; его нельзя разделять или сопоставлять по правилам клиентских моделей.", + "This session will lose access immediately and must sign in again.": "Этот сеанс немедленно потеряет доступ, и потребуется повторный вход.", "This site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}", "This tier catches any request that did not match earlier tiers.": "Этот уровень обрабатывает все запросы, которые не совпали с предыдущими уровнями.", "this token group": "эта группа токенов", @@ -4726,9 +4760,11 @@ "Type-Specific Settings": "Настройки для конкретного типа", "Type:": "Тип:", "UI granularity only — data is still aggregated hourly": "Только детализация пользовательского интерфейса — данные по-прежнему агрегируются ежечасно", + "Unable to build Passkey assertion": "Не удалось создать утверждение Passkey", "Unable to estimate price for this deployment.": "Не удается оценить цену для этого развертывания.", "Unable to generate chat link. Please contact your administrator.": "Не удалось сгенерировать ссылку для чата. Пожалуйста, свяжитесь с вашим администратором.", "Unable to load groups": "Не удалось загрузить группы", + "Unable to load login sessions": "Не удалось загрузить сеансы входа", "Unable to load rankings": "Не удалось загрузить рейтинги", "Unable to load rankings data": "Не удалось загрузить данные рейтингов", "Unable to open chat": "Не удалось открыть чат", @@ -4752,12 +4788,14 @@ "Unit price must be greater than 0": "Цена за единицу должна быть больше 0", "Units per USD": "Единиц за USD", "Unknown": "Неизвестно", + "Unknown device": "Неизвестное устройство", "Unknown version": "Неизвестная версия", "Unlimited": "Без ограничений", "Unlimited Quota": "Неограниченная квота", "Unsaved changes": "Несохранённые изменения", "Unset price": "Цена не задана", "Unset price models": "Модели с неустановленной ценой", + "Unsupported verification method: {{method}}": "Неподдерживаемый способ проверки: {{method}}", "Until": "До", "Untitled": "Без названия", "Untrusted upstream data:": "Недоверенные вышестоящие данные:", @@ -4871,6 +4909,7 @@ "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Для этого канала будет использован один доступный сброс. Запрос отправляется только после подтверждения.", "Use one available reset credit to refresh the current Codex usage windows.": "Использует один доступный сброс, чтобы обновить текущие окна использования Codex.", "Use our unified OpenAI-compatible endpoint in your applications": "Используйте наш единый OpenAI-совместимый эндпоинт в ваших приложениях", + "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "Подтвердите личность с помощью Passkey или 2FA перед просмотром ключа канала.", "Use Passkey to sign in without entering your password.": "Используйте ключ доступа для входа без ввода пароля.", "Use presets or upstream discovery to populate the model list faster.": "Используйте пресеты или обнаружение upstream, чтобы быстрее заполнить список моделей.", "Use secure connection when sending emails": "Использовать безопасное соединение при отправке электронных писем", @@ -4966,12 +5005,15 @@ "Verification code updates every 30 seconds.": "Код подтверждения обновляется каждые 30 секунд.", "Verification email sent": "Письмо для подтверждения отправлено", "Verification failed": "Подтверждение не удалось", + "Verification flow expired": "Процесс проверки истёк", "Verification is not configured properly": "Подтверждение настроено неправильно", + "Verification proof was not returned": "Подтверждение проверки не было получено", "Verification required to reveal the saved key.": "Требуется подтверждение для отображения сохраненного ключа.", "Verify": "Проверить", "Verify and Sign In": "Подтвердить и войти", "Verify routing with Playground or your client": "Проверьте маршрутизацию через Playground или ваш клиент", "Verify Setup": "Проверить настройку", + "Verify to view channel key": "Подтвердить для просмотра ключа канала", "Verify your database connection": "Проверьте подключение к базе данных", "Verifying credentials and pulling stores from your Pancake account...": "Проверяем учетные данные и загружаем магазины из вашего аккаунта Pancake...", "Version": "Версия", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 342322b8b6e3..277921fd5d4c 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -657,6 +657,7 @@ "Browse and compare": "Duyệt và so sánh", "Browse available models and pricing": "Duyệt mô hình khả dụng và giá", "Browse rankings by category": "Duyệt bảng xếp hạng theo danh mục", + "Browser": "Trình duyệt", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "Số token ngân sách = số token tối đa × tỷ lệ. Chấp nhận một số thập phân từ 0.002 đến 1. Khuyến nghị nên giữ cho phù hợp với cách tính phí của nhà cung cấp.", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "Số token ngân sách = số token tối đa × tỷ lệ. Chấp nhận một số thập phân từ 0.1 đến 1.", "Budget Tokens Ratio": "Tỷ lệ Mã thông báo Ngân sách", @@ -1181,6 +1182,7 @@ "Cross-group retry": "Thử lại liên nhóm", "Currency": "Tiền tệ", "Currency & Display": "Tiền tệ & hiển thị", + "Current": "Hiện tại", "Current Balance": "Số Dư Hiện Tại", "Current Billing": "Thanh toán hiện tại", "Current Cache Size": "Kích thước bộ nhớ đệm hiện tại", @@ -1721,6 +1723,7 @@ "Estimated cost": "Chi phí ước tính", "Estimated quota cost": "Ước tính chi phí hạn mức", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Mỗi tên nhóm trong bảng định giá có thể dùng ở hai nơi: trên người dùng (nhóm người dùng, do quản trị viên gán) và trên token (nhóm token, chọn khi tạo token). Cùng một bộ tên, hai vai trò khác nhau.", + "Every other device will lose access immediately. This device will remain signed in.": "Mọi thiết bị khác sẽ mất quyền truy cập ngay lập tức. Thiết bị này vẫn duy trì đăng nhập.", "Everything configured for this group, in one place.": "Toàn bộ cấu hình của nhóm này, tại một nơi.", "Exact": "Chính xác", "Exact Match": "Khớp chính xác", @@ -1848,6 +1851,7 @@ "Failed to load home page content": "Không thể tải nội dung trang chủ", "Failed to load image": "Không thể tải ảnh", "Failed to load key status": "Không thể tải trạng thái khóa", + "Failed to load login sessions": "Không thể tải các phiên đăng nhập", "Failed to load logs": "Không tải được nhật ký", "Failed to load Passkey status": "Không thể tải trạng thái Passkey", "Failed to load playground groups": "Tải nhóm playground thất bại", @@ -1884,6 +1888,8 @@ "Failed to send verification email": "Gửi email xác minh thất bại", "Failed to set tag": "Không thể đặt thẻ", "Failed to setup 2FA": "Không thể thiết lập 2FA", + "Failed to sign out other sessions": "Không thể đăng xuất các phiên khác", + "Failed to sign out session": "Không thể đăng xuất phiên", "Failed to start {{provider}} login": "Không thể bắt đầu đăng nhập {{provider}}", "Failed to start Discord login": "Không thể bắt đầu đăng nhập Discord", "Failed to start GitHub login": "Không thể bắt đầu đăng nhập GitHub", @@ -1891,7 +1897,9 @@ "Failed to start OIDC login": "Không thể bắt đầu đăng nhập OIDC", "Failed to start Passkey login": "Không thể bắt đầu đăng nhập Passkey", "Failed to start Passkey registration": "Không thể bắt đầu đăng ký Passkey", + "Failed to start Telegram binding": "Không thể bắt đầu liên kết Telegram", "Failed to start testing all channels": "Không thể bắt đầu kiểm tra tất cả các kênh", + "Failed to start verification": "Không thể bắt đầu xác minh", "Failed to sync prices": "Không thể đồng bộ giá", "Failed to sync ratios": "Không thể đồng bộ tỷ lệ", "Failed to test all channels": "Không thể kiểm tra tất cả các kênh", @@ -2360,6 +2368,7 @@ "IP Filter Mode": "Lọc IP", "IP Restriction": "Giới hạn IP", "IP Whitelist (supports CIDR)": "Danh sách trắng IP (hỗ trợ CIDR)", + "IP: {{ip}} · Method: {{method}}": "IP: {{ip}} · Phương thức: {{method}}", "is less than the configured maximum cache size": "nhỏ hơn kích thước bộ nhớ đệm tối đa đã cấu hình", "is the default price; ": "là giá mặc định; ", "It seems like the page you're looking for": "Có vẻ như trang bạn đang tìm kiếm", @@ -2415,6 +2424,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "Tùy chọn ngôn ngữ sẽ đồng bộ trên các thiết bị đã đăng nhập và ảnh hưởng đến ngôn ngữ thông báo lỗi API.", "Last 24h usage": "Sử dụng 24h qua", "Last 30 days uptime": "Uptime 30 ngày qua", + "Last active {{time}} · Expires {{expires}}": "Hoạt động gần nhất {{time}} · Hết hạn {{expires}}", "Last check time": "Thời gian kiểm tra gần nhất", "Last detected addable models": "Mô hình có thể thêm được phát hiện gần nhất", "Last Login": "Lần đăng nhập cuối", @@ -2520,8 +2530,10 @@ "Logic": "Logic", "Login": "Đăng nhập", "Login failed": "Đăng nhập thất bại", + "Login flow expired. Please sign in again.": "Quy trình đăng nhập đã hết hạn. Vui lòng đăng nhập lại.", "Login Info": "Thông tin đăng nhập", "Login Method": "Phương thức đăng nhập", + "Login sessions": "Phiên đăng nhập", "Logo": "Logo", "Logo URL": "URL Logo", "Logs": "Nhật ký", @@ -2823,6 +2835,7 @@ "No": "Không", "No About Content Set": "Chưa đặt nội dung Giới thiệu", "No Active": "Không hoạt động", + "No active login sessions": "Không có phiên đăng nhập đang hoạt động", "No active system tasks.": "Không có tác vụ hệ thống đang hoạt động.", "No additional type-specific settings for this channel type.": "Không có cài đặt bổ sung cụ thể theo loại cho loại kênh này.", "No amount options configured. Add amounts below to get started.": "Chưa có tùy chọn số tiền nào được cấu hình. Thêm các số tiền bên dưới để bắt đầu.", @@ -3026,11 +3039,14 @@ "Number of tokens per unit quota": "Số token trên đơn vị hạn mức", "Number of top log probabilities returned per token": "Số log probabilities hàng đầu trên mỗi token", "Number of users invited": "Số người dùng được mời", + "OAuth binding timed out. Please try again.": "Liên kết OAuth đã hết thời gian chờ. Vui lòng thử lại.", + "OAuth binding window is no longer available": "Cửa sổ liên kết OAuth không còn khả dụng", "OAuth callback URL": "URL callback OAuth", "OAuth Client ID": "ID Client OAuth", "OAuth Client Secret": "Bí mật OAuth Client", "OAuth failed": "OAuth thất bại", "OAuth Integrations": "Tích hợp OAuth", + "OAuth pop-up was blocked": "Cửa sổ bật lên OAuth đã bị chặn", "Object Prune Rules": "Quy tắc dọn dẹp đối tượng", "Observability": "Khả năng quan sát", "Obtain the API key, merchant ID, and RSA key pair from the Waffo dashboard, and configure the callback URL.": "Lấy API key, mã thương gia và cặp khóa RSA từ bảng điều khiển Waffo, đồng thời cấu hình URL callback.", @@ -3157,6 +3173,7 @@ "Other groups": "Nhóm khác", "Other models": "Mô hình khác", "Other nodes": "Nút khác", + "Other sessions signed out": "Đã đăng xuất các phiên khác", "Other tokens": "Token khác", "Other users": "Người dùng khác", "Outage": "Gián đoạn", @@ -3238,6 +3255,11 @@ "Passkey registration was cancelled": "Đăng ký Passkey đã bị hủy", "Passkey removed successfully": "Đã xóa Passkey thành công", "Passkey reset successfully": "Đặt lại Passkey thành công", + "Passkey verification failed": "Xác minh Passkey thất bại", + "Passkey verification is not available in the current state": "Xác minh Passkey không khả dụng trong trạng thái hiện tại", + "Passkey verification is not supported in this environment": "Môi trường này không hỗ trợ xác minh Passkey", + "Passkey verification was cancelled": "Đã hủy xác minh Passkey", + "Passkey verification was cancelled or timed out": "Xác minh Passkey đã bị hủy hoặc hết thời gian chờ", "Passthrough Template": "Mẫu truyền qua", "Password": "Mật khẩu", "Password / Access Token": "Mật khẩu / Access Token", @@ -3366,6 +3388,7 @@ "Please enter the authentication code.": "Vui lòng nhập mã xác thực.", "Please enter the URL": "Vui lòng nhập URL", "Please enter the verification code": "Vui lòng nhập mã xác minh", + "Please enter the verification code or backup code": "Vui lòng nhập mã xác minh hoặc mã dự phòng", "Please enter your current password": "Vui lòng nhập mật khẩu hiện tại của bạn", "Please enter your email": "Vui lòng nhập email của bạn", "Please enter your email first": "Vui lòng nhập email trước", @@ -3664,6 +3687,7 @@ "Refresh failed": "Làm mới thất bại", "Refresh interval (minutes)": "Khoảng thời gian làm mới (phút)", "Refresh Stats": "Làm mới thống kê", + "Refresh the list and try again.": "Hãy làm mới danh sách và thử lại.", "Refreshing...": "Đang làm mới...", "Refund": "Hoàn tiền", "Refund Details": "Chi tiết hoàn tiền", @@ -3676,6 +3700,7 @@ "Register Passkey": "Đăng ký Passkey", "Registered a passkey": "Đã đăng ký một passkey", "Registration Enabled": "Đăng ký đã bật", + "Registration flow expired. Please try again.": "Quy trình đăng ký đã hết hạn. Vui lòng thử lại.", "Registry (optional)": "Registry (tùy chọn)", "Registry secret": "Bí mật Registry", "Registry username": "Tên người dùng Registry", @@ -3847,9 +3872,12 @@ "Reveal key": "Display key", "Revenue": "Doanh thu", "Review & initialize": "Xem lại và khởi tạo", + "Review and sign out devices currently using your account.": "Xem lại và đăng xuất các thiết bị hiện đang sử dụng tài khoản của bạn.", "Review model rates before scaling traffic": "Xem giá mô hình trước khi mở rộng lưu lượng", "Review your payment details": "Xem lại chi tiết thanh toán của bạn", "Review your purchase details before proceeding.": "Xem lại chi tiết mua hàng trước khi tiếp tục.", + "Revoke": "Thu hồi", + "Revoke session?": "Thu hồi phiên này?", "Rewards will be added directly to your balance": "Phần thưởng sẽ được thêm trực tiếp vào số dư của bạn", "Rewrite callback URLs to the local server": "Viết lại URL callback đến máy chủ cục bộ", "Right to Left": "Phải sang trái", @@ -4100,6 +4128,7 @@ "Session": "Phiên", "Session expired!": "Phiên hết hạn!", "Session expired?": "Phiên đã hết hạn?", + "Session signed out": "Đã đăng xuất phiên", "Set": "Đặt", "Set a discount rate for a specific recharge amount threshold.": "Đặt tỷ lệ chiết khấu cho một ngưỡng số tiền nạp cụ thể.", "Set a secure password (min. 8 characters)": "Đặt mật khẩu an toàn (tối thiểu 8 ký tự)", @@ -4164,6 +4193,10 @@ "Sign in required": "Cần đăng nhập", "Sign in with Passkey": "Đăng nhập bằng Passkey", "Sign out": "Đăng xuất", + "Sign out other sessions": "Đăng xuất các phiên khác", + "Sign out other sessions?": "Đăng xuất các phiên khác?", + "Sign out others": "Đăng xuất các phiên khác", + "Sign out this device?": "Đăng xuất thiết bị này?", "Sign up": "Đăng ký", "Signed in": "Đã đăng nhập", "Signed in successfully!": "Đã đăng nhập thành công!", @@ -4519,6 +4552,7 @@ "This project must be used in compliance with the": "Dự án này phải được sử dụng tuân thủ theo", "This removes {{count}} failed models from this channel. This action cannot be undone.": "Thao tác này sẽ xóa {{count}} mô hình thất bại khỏi kênh này. Không thể hoàn tác.", "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Tuyến này khám phá các mô hình OpenAI thượng nguồn và không thể tách hoặc đối sánh bằng quy tắc mô hình phía máy khách.", + "This session will lose access immediately and must sign in again.": "Phiên này sẽ mất quyền truy cập ngay lập tức và phải đăng nhập lại.", "This site currently has {{count}} models enabled": "Trang này hiện đã bật {{count}} mô hình", "This tier catches any request that did not match earlier tiers.": "Tầng này bắt mọi yêu cầu không khớp với các tầng trước.", "this token group": "nhóm token này", @@ -4726,9 +4760,11 @@ "Type-Specific Settings": "Cài đặt theo loại", "Type:": "Loại:", "UI granularity only — data is still aggregated hourly": "Chỉ là độ chi tiết UI — dữ liệu vẫn được tổng hợp theo giờ", + "Unable to build Passkey assertion": "Không thể tạo xác nhận Passkey", "Unable to estimate price for this deployment.": "Không thể ước tính giá cho triển khai này.", "Unable to generate chat link. Please contact your administrator.": "Không thể tạo liên kết trò chuyện. Vui lòng liên hệ quản trị viên của bạn.", "Unable to load groups": "Không thể tải nhóm", + "Unable to load login sessions": "Không thể tải các phiên đăng nhập", "Unable to load rankings": "Không thể tải bảng xếp hạng", "Unable to load rankings data": "Không thể tải dữ liệu bảng xếp hạng", "Unable to open chat": "Không thể mở trò chuyện", @@ -4752,12 +4788,14 @@ "Unit price must be greater than 0": "Đơn giá phải lớn hơn 0", "Units per USD": "Đơn vị trên USD", "Unknown": "Không rõ", + "Unknown device": "Thiết bị không xác định", "Unknown version": "Phiên bản không xác định", "Unlimited": "Không giới hạn", "Unlimited Quota": "Hạn mức không giới hạn", "Unsaved changes": "Thay đổi chưa được lưu", "Unset price": "Chưa đặt giá", "Unset price models": "Mô hình chưa thiết lập giá", + "Unsupported verification method: {{method}}": "Phương thức xác minh không được hỗ trợ: {{method}}", "Until": "Cho đến", "Untitled": "Không có tiêu đề", "Untrusted upstream data:": "Dữ liệu nguồn không đáng tin cậy:", @@ -4871,6 +4909,7 @@ "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Sử dụng một lượt đặt lại khả dụng cho kênh này. Yêu cầu chỉ được gửi sau khi xác nhận.", "Use one available reset credit to refresh the current Codex usage windows.": "Sử dụng một lượt đặt lại khả dụng để làm mới các cửa sổ mức dùng Codex hiện tại.", "Use our unified OpenAI-compatible endpoint in your applications": "Sử dụng endpoint thống nhất tương thích OpenAI trong ứng dụng của bạn", + "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "Hãy dùng Passkey hoặc 2FA để xác nhận danh tính trước khi xem khóa kênh này.", "Use Passkey to sign in without entering your password.": "Sử dụng Khóa truy cập để đăng nhập mà không cần nhập mật khẩu của bạn.", "Use presets or upstream discovery to populate the model list faster.": "Dùng mẫu đặt sẵn hoặc phát hiện từ upstream để điền danh sách mô hình nhanh hơn.", "Use secure connection when sending emails": "Sử dụng kết nối an toàn khi gửi email", @@ -4966,12 +5005,15 @@ "Verification code updates every 30 seconds.": "Mã xác minh cập nhật mỗi 30 giây.", "Verification email sent": "Email xác thực đã được gửi", "Verification failed": "Xác thực thất bại", + "Verification flow expired": "Quy trình xác minh đã hết hạn", "Verification is not configured properly": "Xác thực chưa được cấu hình đúng cách", + "Verification proof was not returned": "Không nhận được bằng chứng xác minh", "Verification required to reveal the saved key.": "Yêu cầu xác minh để tiết lộ khóa đã lưu.", "Verify": "Kiểm tra", "Verify and Sign In": "Xác minh và Đăng nhập", "Verify routing with Playground or your client": "Xác minh định tuyến bằng Playground hoặc client của bạn", "Verify Setup": "Xác minh thiết lập", + "Verify to view channel key": "Xác minh để xem khóa kênh", "Verify your database connection": "Xác minh kết nối cơ sở dữ liệu của bạn", "Verifying credentials and pulling stores from your Pancake account...": "Đang xác minh thông tin xác thực và lấy cửa hàng từ tài khoản Pancake của bạn...", "Version": "Phiên bản", diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index 111d2def47a6..10a83fb0ef36 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -657,6 +657,7 @@ "Browse and compare": "瀏覽和比較", "Browse available models and pricing": "瀏覽可用模型和價格", "Browse rankings by category": "按行業瀏覽排行", + "Browser": "瀏覽器", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "預算令牌 = 最大令牌數 × 比例。接受 0.002 到 1 之間的十進制數。建議與上游收費保持一致。", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "預算令牌 = 最大令牌數 × 比例。接受 0.1 到 1 之間的十進制數。", "Budget Tokens Ratio": "預算令牌比例", @@ -1181,6 +1182,7 @@ "Cross-group retry": "跨分組重試", "Currency": "貨幣", "Currency & Display": "貨幣與展示", + "Current": "目前", "Current Balance": "目前餘額", "Current Billing": "目前收費", "Current Cache Size": "目前緩存大小", @@ -1721,6 +1723,7 @@ "Estimated cost": "預計成本", "Estimated quota cost": "估算配額費用", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "定價表中的每個分組名可用在兩個地方:用戶身上(用戶分組,由管理員分配)和令牌身上(令牌分組,建立令牌時選擇)。同一批名字,兩種不同職責。", + "Every other device will lose access immediately. This device will remain signed in.": "其他所有裝置將立即失去存取權限,目前裝置將保持登入。", "Everything configured for this group, in one place.": "該分組的全部設定,一處看全。", "Exact": "精確", "Exact Match": "完全匹配", @@ -1848,6 +1851,7 @@ "Failed to load home page content": "載入首頁內容失敗", "Failed to load image": "無法載入圖像", "Failed to load key status": "載入金鑰狀態失敗", + "Failed to load login sessions": "載入登入工作階段失敗", "Failed to load logs": "載入日誌失敗", "Failed to load Passkey status": "載入 Passkey 狀態失敗", "Failed to load playground groups": "載入 playground 分組失敗", @@ -1884,6 +1888,8 @@ "Failed to send verification email": "發送驗證郵件失敗", "Failed to set tag": "設定標籤失敗", "Failed to setup 2FA": "設定 2FA 失敗", + "Failed to sign out other sessions": "登出其他工作階段失敗", + "Failed to sign out session": "登出工作階段失敗", "Failed to start {{provider}} login": "啟動 {{provider}} 登入失敗", "Failed to start Discord login": "啟動 Discord 登入失敗", "Failed to start GitHub login": "啟動 GitHub 登入失敗", @@ -1891,7 +1897,9 @@ "Failed to start OIDC login": "啟動 OIDC 登入失敗", "Failed to start Passkey login": "無法啟動 Passkey 登入", "Failed to start Passkey registration": "啟動 Passkey 註冊失敗", + "Failed to start Telegram binding": "啟動 Telegram 綁定失敗", "Failed to start testing all channels": "無法開始測試所有渠道", + "Failed to start verification": "啟動驗證失敗", "Failed to sync prices": "同步價格失敗", "Failed to sync ratios": "同步比率失敗", "Failed to test all channels": "無法測試所有渠道", @@ -2360,6 +2368,7 @@ "IP Filter Mode": "IP 過濾模式", "IP Restriction": "IP 限制", "IP Whitelist (supports CIDR)": "IP 白名單(支援 CIDR 表達式)", + "IP: {{ip}} · Method: {{method}}": "IP:{{ip}} · 登入方式:{{method}}", "is less than the configured maximum cache size": "小於設定的最大緩存大小", "is the default price; ": "為預設價格;", "It seems like the page you're looking for": "您要查找的頁面似乎", @@ -2415,6 +2424,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "語言偏好會同步到您登入的所有設備,並影響 API 錯誤訊息語言。", "Last 24h usage": "近 24 小時消耗", "Last 30 days uptime": "近 30 天可用率", + "Last active {{time}} · Expires {{expires}}": "最後活動於 {{time}} · 到期時間 {{expires}}", "Last check time": "上次檢測時間", "Last detected addable models": "上次檢測到可加入模型", "Last Login": "最後登入", @@ -2520,8 +2530,10 @@ "Logic": "邏輯", "Login": "登入", "Login failed": "登入失敗", + "Login flow expired. Please sign in again.": "登入流程已過期,請重新登入。", "Login Info": "登入資訊", "Login Method": "登入方式", + "Login sessions": "登入工作階段", "Logo": "徽標", "Logo URL": "徽標 URL", "Logs": "日誌", @@ -2823,6 +2835,7 @@ "No": "否", "No About Content Set": "未設定關於內容", "No Active": "無生效", + "No active login sessions": "沒有使用中的登入工作階段", "No active system tasks.": "暫無進行中的系統任務。", "No additional type-specific settings for this channel type.": "此渠道類型沒有額外的特定類型設定。", "No amount options configured. Add amounts below to get started.": "未設定金額選項。在下方新增金額即可開始使用。", @@ -3026,11 +3039,14 @@ "Number of tokens per unit quota": "每單位配額的令牌數", "Number of top log probabilities returned per token": "每個 token 返回的 top 概率數量", "Number of users invited": "已邀請的用戶數量", + "OAuth binding timed out. Please try again.": "OAuth 綁定逾時,請重試。", + "OAuth binding window is no longer available": "OAuth 綁定視窗已無法使用", "OAuth callback URL": "OAuth 回呼 URL", "OAuth Client ID": "OAuth 用戶端 ID", "OAuth Client Secret": "OAuth 用戶端密鑰", "OAuth failed": "OAuth 失敗", "OAuth Integrations": "OAuth 整合", + "OAuth pop-up was blocked": "OAuth 彈出式視窗遭瀏覽器封鎖", "Object Prune Rules": "物件清理規則", "Observability": "可觀測性", "Obtain the API key, merchant ID, and RSA key pair from the Waffo dashboard, and configure the callback URL.": "請在 Waffo 後台獲取 API 金鑰、商戶 ID 以及 RSA 金鑰對,並設定Callback地址。", @@ -3157,6 +3173,7 @@ "Other groups": "其他分組", "Other models": "其他模型", "Other nodes": "其他節點", + "Other sessions signed out": "已登出其他工作階段", "Other tokens": "其他令牌", "Other users": "其他用戶", "Outage": "中斷", @@ -3238,6 +3255,11 @@ "Passkey registration was cancelled": "通行金鑰註冊已取消", "Passkey removed successfully": "通行金鑰已成功移除", "Passkey reset successfully": "通行金鑰已成功重置", + "Passkey verification failed": "Passkey 驗證失敗", + "Passkey verification is not available in the current state": "目前狀態下無法使用 Passkey 驗證", + "Passkey verification is not supported in this environment": "目前環境不支援 Passkey 驗證", + "Passkey verification was cancelled": "Passkey 驗證已取消", + "Passkey verification was cancelled or timed out": "Passkey 驗證已取消或逾時", "Passthrough Template": "透傳模板", "Password": "密碼", "Password / Access Token": "密碼 / 存取令牌", @@ -3366,6 +3388,7 @@ "Please enter the authentication code.": "請輸入驗證碼。", "Please enter the URL": "請輸入 URL", "Please enter the verification code": "請輸入驗證碼", + "Please enter the verification code or backup code": "請輸入驗證碼或備用碼", "Please enter your current password": "請輸入目前密碼", "Please enter your email": "請輸入您的電郵", "Please enter your email first": "請先輸入您的電郵", @@ -3664,6 +3687,7 @@ "Refresh failed": "重新整理失敗", "Refresh interval (minutes)": "重新整理間隔 (分鐘)", "Refresh Stats": "重新整理統計", + "Refresh the list and try again.": "請重新整理清單後再試一次。", "Refreshing...": "重新整理中...", "Refund": "退款", "Refund Details": "退款詳情", @@ -3676,6 +3700,7 @@ "Register Passkey": "註冊 Passkey", "Registered a passkey": "註冊了一個 Passkey", "Registration Enabled": "註冊已啟用", + "Registration flow expired. Please try again.": "註冊流程已過期,請再試一次。", "Registry (optional)": "註冊表 (可選)", "Registry secret": "註冊表金鑰", "Registry username": "註冊表用戶名", @@ -3847,9 +3872,12 @@ "Reveal key": "顯示金鑰", "Revenue": "收入", "Review & initialize": "審核並初始化", + "Review and sign out devices currently using your account.": "查看並登出目前正在使用您帳號的裝置。", "Review model rates before scaling traffic": "擴展流量前查看模型費率", "Review your payment details": "查看您的付款詳情", "Review your purchase details before proceeding.": "在繼續之前,請審閱您的購買詳情。", + "Revoke": "撤銷", + "Revoke session?": "撤銷此工作階段?", "Rewards will be added directly to your balance": "獎勵將直接新增到您的餘額", "Rewrite callback URLs to the local server": "將Callback URL 重寫到本地伺服器", "Right to Left": "從右到左", @@ -4100,6 +4128,7 @@ "Session": "對話", "Session expired!": "對話已過期!", "Session expired?": "對話已過期?", + "Session signed out": "已登出此工作階段", "Set": "設定", "Set a discount rate for a specific recharge amount threshold.": "為特定的儲值金額閾值設定折扣率。", "Set a secure password (min. 8 characters)": "設定安全密碼(最少 8 個字元)", @@ -4164,6 +4193,10 @@ "Sign in required": "需要登入", "Sign in with Passkey": "使用 Passkey 登入", "Sign out": "登出", + "Sign out other sessions": "登出其他工作階段", + "Sign out other sessions?": "登出其他工作階段?", + "Sign out others": "登出其他工作階段", + "Sign out this device?": "登出目前裝置?", "Sign up": "註冊", "Signed in": "已登入", "Signed in successfully!": "登入成功!", @@ -4519,6 +4552,7 @@ "This project must be used in compliance with the": "此項目的使用必須遵守", "This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作將從該渠道移除 {{count}} 個測試失敗的模型,且無法撤銷。", "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "此路由用於探索上游 OpenAI 模型,無法拆分或使用用戶端模型規則配對。", + "This session will lose access immediately and must sign in again.": "此工作階段將立即失去存取權限,且必須重新登入。", "This site currently has {{count}} models enabled": "本站目前已啟用模型,總計 {{count}} 個", "This tier catches any request that did not match earlier tiers.": "此階梯會兜底處理未匹配前面階梯的請求。", "this token group": "此令牌分組", @@ -4726,9 +4760,11 @@ "Type-Specific Settings": "特定類型設定", "Type:": "類型:", "UI granularity only — data is still aggregated hourly": "僅 UI 粒度 — 數據仍按小時匯總", + "Unable to build Passkey assertion": "無法建立 Passkey 斷言", "Unable to estimate price for this deployment.": "無法為該部署估算價格。", "Unable to generate chat link. Please contact your administrator.": "無法生成聊天連結。請聯絡您的管理員。", "Unable to load groups": "無法載入分組", + "Unable to load login sessions": "無法載入登入工作階段", "Unable to load rankings": "無法載入排行榜", "Unable to load rankings data": "無法載入排行榜數據", "Unable to open chat": "無法打開聊天", @@ -4752,12 +4788,14 @@ "Unit price must be greater than 0": "單價必須大於 0", "Units per USD": "每 USD 單位數", "Unknown": "未知", + "Unknown device": "未知裝置", "Unknown version": "未知版本", "Unlimited": "無限制", "Unlimited Quota": "無限配額", "Unsaved changes": "未儲存的變更", "Unset price": "未設定價格", "Unset price models": "未設定價格模型", + "Unsupported verification method: {{method}}": "不支援的驗證方式:{{method}}", "Until": "至", "Untitled": "未命名", "Untrusted upstream data:": "不受信任的上游數據:", @@ -4871,6 +4909,7 @@ "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "將為目前渠道使用 1 次可用重置次數。只有確認後才會發送重置請求。", "Use one available reset credit to refresh the current Codex usage windows.": "使用 1 次可用重置次數,重新整理目前 Codex 用量窗口。", "Use our unified OpenAI-compatible endpoint in your applications": "在套用中使用我們兼容 OpenAI 的統一接口", + "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "請使用 Passkey 或雙重身份驗證確認身份後再查看此渠道金鑰。", "Use Passkey to sign in without entering your password.": "使用通行金鑰登入,無需輸入密碼。", "Use presets or upstream discovery to populate the model list faster.": "使用預設或上游發現來更快填充模型列表。", "Use secure connection when sending emails": "發送電郵時使用安全連接", @@ -4966,12 +5005,15 @@ "Verification code updates every 30 seconds.": "驗證碼每 30 秒更新一次。", "Verification email sent": "驗證郵件已發送", "Verification failed": "驗證失敗", + "Verification flow expired": "驗證流程已過期", "Verification is not configured properly": "驗證未正確設定", + "Verification proof was not returned": "伺服器未傳回驗證憑證", "Verification required to reveal the saved key.": "需要驗證才能顯示已儲存的金鑰。", "Verify": "驗證", "Verify and Sign In": "驗證並登入", "Verify routing with Playground or your client": "使用 Playground 或你的用戶端驗證路由", "Verify Setup": "驗證設定", + "Verify to view channel key": "驗證後查看渠道金鑰", "Verify your database connection": "驗證資料庫連接", "Verifying credentials and pulling stores from your Pancake account...": "正在驗證憑證並從你的 Pancake 用戶拉取店鋪...", "Version": "版本", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 4f1412ac259a..6a1fded65e2d 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -657,6 +657,7 @@ "Browse and compare": "浏览和比较", "Browse available models and pricing": "浏览可用模型和价格", "Browse rankings by category": "按行业浏览排行", + "Browser": "浏览器", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "预算令牌 = 最大令牌数 × 比例。接受 0.002 到 1 之间的十进制数。建议与上游计费保持一致。", "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "预算令牌 = 最大令牌数 × 比例。接受 0.1 到 1 之间的十进制数。", "Budget Tokens Ratio": "预算令牌比例", @@ -1181,6 +1182,7 @@ "Cross-group retry": "跨分组重试", "Currency": "货币", "Currency & Display": "货币与展示", + "Current": "当前", "Current Balance": "当前余额", "Current Billing": "当前计费", "Current Cache Size": "当前缓存大小", @@ -1721,6 +1723,7 @@ "Estimated cost": "预计成本", "Estimated quota cost": "估算配额费用", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "定价表中的每个分组名可用在两个地方:用户身上(用户分组,由管理员分配)和令牌身上(令牌分组,创建令牌时选择)。同一批名字,两种不同职责。", + "Every other device will lose access immediately. This device will remain signed in.": "其他所有设备将立即失去访问权限,当前设备将保持登录。", "Everything configured for this group, in one place.": "该分组的全部配置,一处看全。", "Exact": "精确", "Exact Match": "完全匹配", @@ -1848,6 +1851,7 @@ "Failed to load home page content": "加载首页内容失败", "Failed to load image": "无法加载图像", "Failed to load key status": "加载密钥状态失败", + "Failed to load login sessions": "加载登录会话失败", "Failed to load logs": "加载日志失败", "Failed to load Passkey status": "加载 Passkey 状态失败", "Failed to load playground groups": "加载 playground 分组失败", @@ -1884,6 +1888,8 @@ "Failed to send verification email": "发送验证邮件失败", "Failed to set tag": "设置标签失败", "Failed to setup 2FA": "设置 2FA 失败", + "Failed to sign out other sessions": "退出其他登录会话失败", + "Failed to sign out session": "退出登录会话失败", "Failed to start {{provider}} login": "启动 {{provider}} 登录失败", "Failed to start Discord login": "启动 Discord 登录失败", "Failed to start GitHub login": "启动 GitHub 登录失败", @@ -1891,7 +1897,9 @@ "Failed to start OIDC login": "启动 OIDC 登录失败", "Failed to start Passkey login": "无法启动 Passkey 登录", "Failed to start Passkey registration": "启动 Passkey 注册失败", + "Failed to start Telegram binding": "启动 Telegram 绑定失败", "Failed to start testing all channels": "无法开始测试所有渠道", + "Failed to start verification": "启动验证失败", "Failed to sync prices": "同步价格失败", "Failed to sync ratios": "同步比率失败", "Failed to test all channels": "无法测试所有渠道", @@ -2360,6 +2368,7 @@ "IP Filter Mode": "IP 过滤模式", "IP Restriction": "IP 限制", "IP Whitelist (supports CIDR)": "IP 白名单(支持 CIDR 表达式)", + "IP: {{ip}} · Method: {{method}}": "IP:{{ip}} · 登录方式:{{method}}", "is less than the configured maximum cache size": "小于配置的最大缓存大小", "is the default price; ": "为默认价格;", "It seems like the page you're looking for": "您要查找的页面似乎", @@ -2415,6 +2424,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "语言偏好会同步到您登录的所有设备,并影响 API 错误消息语言。", "Last 24h usage": "近 24 小时消耗", "Last 30 days uptime": "近 30 天可用率", + "Last active {{time}} · Expires {{expires}}": "最后活跃于 {{time}} · 到期时间 {{expires}}", "Last check time": "上次检测时间", "Last detected addable models": "上次检测到可加入模型", "Last Login": "最后登录", @@ -2520,8 +2530,10 @@ "Logic": "逻辑", "Login": "登录", "Login failed": "登录失败", + "Login flow expired. Please sign in again.": "登录流程已过期,请重新登录。", "Login Info": "登录信息", "Login Method": "登录方式", + "Login sessions": "登录会话", "Logo": "徽标", "Logo URL": "徽标 URL", "Logs": "日志", @@ -2823,6 +2835,7 @@ "No": "否", "No About Content Set": "未设置关于内容", "No Active": "无生效", + "No active login sessions": "没有活跃的登录会话", "No active system tasks.": "暂无进行中的系统任务。", "No additional type-specific settings for this channel type.": "此渠道类型没有额外的特定类型设置。", "No amount options configured. Add amounts below to get started.": "未配置金额选项。在下方添加金额即可开始使用。", @@ -3026,11 +3039,14 @@ "Number of tokens per unit quota": "每单位配额的令牌数", "Number of top log probabilities returned per token": "每个 token 返回的 top 概率数量", "Number of users invited": "已邀请的用户数量", + "OAuth binding timed out. Please try again.": "OAuth 绑定超时,请重试。", + "OAuth binding window is no longer available": "OAuth 绑定窗口已不可用", "OAuth callback URL": "OAuth 回调 URL", "OAuth Client ID": "OAuth 客户端 ID", "OAuth Client Secret": "OAuth 客户端密钥", "OAuth failed": "OAuth 失败", "OAuth Integrations": "OAuth 集成", + "OAuth pop-up was blocked": "OAuth 弹窗被浏览器拦截", "Object Prune Rules": "对象清理规则", "Observability": "可观测性", "Obtain the API key, merchant ID, and RSA key pair from the Waffo dashboard, and configure the callback URL.": "请在 Waffo 后台获取 API 密钥、商户 ID 以及 RSA 密钥对,并配置回调地址。", @@ -3157,6 +3173,7 @@ "Other groups": "其他分组", "Other models": "其他模型", "Other nodes": "其他节点", + "Other sessions signed out": "已退出其他登录会话", "Other tokens": "其他令牌", "Other users": "其他用户", "Outage": "中断", @@ -3238,6 +3255,11 @@ "Passkey registration was cancelled": "通行密钥注册已取消", "Passkey removed successfully": "通行密钥已成功移除", "Passkey reset successfully": "通行密钥已成功重置", + "Passkey verification failed": "Passkey 验证失败", + "Passkey verification is not available in the current state": "当前状态下无法使用 Passkey 验证", + "Passkey verification is not supported in this environment": "当前环境不支持 Passkey 验证", + "Passkey verification was cancelled": "Passkey 验证已取消", + "Passkey verification was cancelled or timed out": "Passkey 验证已取消或超时", "Passthrough Template": "透传模板", "Password": "密码", "Password / Access Token": "密码 / 访问令牌", @@ -3366,6 +3388,7 @@ "Please enter the authentication code.": "请输入验证码。", "Please enter the URL": "请输入 URL", "Please enter the verification code": "请输入验证码", + "Please enter the verification code or backup code": "请输入验证码或备用码", "Please enter your current password": "请输入当前密码", "Please enter your email": "请输入您的电子邮件", "Please enter your email first": "请先输入您的邮箱", @@ -3664,6 +3687,7 @@ "Refresh failed": "刷新失败", "Refresh interval (minutes)": "刷新间隔 (分钟)", "Refresh Stats": "刷新统计", + "Refresh the list and try again.": "请刷新列表后重试。", "Refreshing...": "刷新中...", "Refund": "退款", "Refund Details": "退款详情", @@ -3676,6 +3700,7 @@ "Register Passkey": "注册 Passkey", "Registered a passkey": "注册了一个 Passkey", "Registration Enabled": "注册已启用", + "Registration flow expired. Please try again.": "注册流程已过期,请重试。", "Registry (optional)": "注册表 (可选)", "Registry secret": "注册表密钥", "Registry username": "注册表用户名", @@ -3847,9 +3872,12 @@ "Reveal key": "显示密钥", "Revenue": "收入", "Review & initialize": "审核并初始化", + "Review and sign out devices currently using your account.": "查看并退出当前正在使用您账号的设备。", "Review model rates before scaling traffic": "扩展流量前查看模型费率", "Review your payment details": "查看您的付款详情", "Review your purchase details before proceeding.": "在继续之前,请审阅您的购买详情。", + "Revoke": "撤销", + "Revoke session?": "撤销此会话?", "Rewards will be added directly to your balance": "奖励将直接添加到您的余额", "Rewrite callback URLs to the local server": "将回调 URL 重写到本地服务器", "Right to Left": "从右到左", @@ -4100,6 +4128,7 @@ "Session": "会话", "Session expired!": "会话已过期!", "Session expired?": "会话已过期?", + "Session signed out": "已退出此会话", "Set": "设置", "Set a discount rate for a specific recharge amount threshold.": "为特定的充值金额阈值设置折扣率。", "Set a secure password (min. 8 characters)": "设置安全密码(最少 8 个字符)", @@ -4164,6 +4193,10 @@ "Sign in required": "需要登录", "Sign in with Passkey": "使用 Passkey 登录", "Sign out": "登出", + "Sign out other sessions": "退出其他会话", + "Sign out other sessions?": "退出其他会话?", + "Sign out others": "退出其他会话", + "Sign out this device?": "退出当前设备?", "Sign up": "注册", "Signed in": "已登录", "Signed in successfully!": "登录成功!", @@ -4519,6 +4552,7 @@ "This project must be used in compliance with the": "此项目的使用必须遵守", "This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作将从该渠道移除 {{count}} 个测试失败的模型,且无法撤销。", "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "此路由用于发现上游 OpenAI 模型,不能拆分或使用客户端模型规则匹配。", + "This session will lose access immediately and must sign in again.": "此会话将立即失去访问权限,并且必须重新登录。", "This site currently has {{count}} models enabled": "本站当前已启用模型,总计 {{count}} 个", "This tier catches any request that did not match earlier tiers.": "此阶梯会兜底处理未匹配前面阶梯的请求。", "this token group": "此令牌分组", @@ -4726,9 +4760,11 @@ "Type-Specific Settings": "特定类型设置", "Type:": "类型:", "UI granularity only — data is still aggregated hourly": "仅 UI 粒度 — 数据仍按小时汇总", + "Unable to build Passkey assertion": "无法生成 Passkey 断言", "Unable to estimate price for this deployment.": "无法为该部署估算价格。", "Unable to generate chat link. Please contact your administrator.": "无法生成聊天链接。请联系您的管理员。", "Unable to load groups": "无法加载分组", + "Unable to load login sessions": "无法加载登录会话", "Unable to load rankings": "无法加载排行榜", "Unable to load rankings data": "无法加载排行榜数据", "Unable to open chat": "无法打开聊天", @@ -4752,12 +4788,14 @@ "Unit price must be greater than 0": "单价必须大于 0", "Units per USD": "每 USD 单位数", "Unknown": "未知", + "Unknown device": "未知设备", "Unknown version": "未知版本", "Unlimited": "无限制", "Unlimited Quota": "无限配额", "Unsaved changes": "未保存的更改", "Unset price": "未设置价格", "Unset price models": "未设置价格模型", + "Unsupported verification method: {{method}}": "不支持的验证方式:{{method}}", "Until": "至", "Untitled": "未命名", "Untrusted upstream data:": "不受信任的上游数据:", @@ -4871,6 +4909,7 @@ "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "将为当前渠道使用 1 次可用重置次数。只有确认后才会发送重置请求。", "Use one available reset credit to refresh the current Codex usage windows.": "使用 1 次可用重置次数,刷新当前 Codex 用量窗口。", "Use our unified OpenAI-compatible endpoint in your applications": "在应用中使用我们兼容 OpenAI 的统一接口", + "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "请使用 Passkey 或双重身份验证确认身份后再查看此渠道密钥。", "Use Passkey to sign in without entering your password.": "使用通行密钥登录,无需输入密码。", "Use presets or upstream discovery to populate the model list faster.": "使用预设或上游发现来更快填充模型列表。", "Use secure connection when sending emails": "发送电子邮件时使用安全连接", @@ -4966,12 +5005,15 @@ "Verification code updates every 30 seconds.": "验证码每 30 秒更新一次。", "Verification email sent": "验证邮件已发送", "Verification failed": "验证失败", + "Verification flow expired": "验证流程已过期", "Verification is not configured properly": "验证未正确配置", + "Verification proof was not returned": "服务端未返回验证凭证", "Verification required to reveal the saved key.": "需要验证才能显示已保存的密钥。", "Verify": "验证", "Verify and Sign In": "验证并登录", "Verify routing with Playground or your client": "使用 Playground 或你的客户端验证路由", "Verify Setup": "验证设置", + "Verify to view channel key": "验证后查看渠道密钥", "Verify your database connection": "验证数据库连接", "Verifying credentials and pulling stores from your Pancake account...": "正在验证凭证并从你的 Pancake 账户拉取店铺...", "Version": "版本", diff --git a/web/default/src/lib/api.ts b/web/default/src/lib/api.ts index 1e50b16c513b..1647f0faae34 100644 --- a/web/default/src/lib/api.ts +++ b/web/default/src/lib/api.ts @@ -16,177 +16,34 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import axios, { type AxiosRequestConfig } from 'axios' -import { t } from 'i18next' -import { toast } from 'sonner' - -import { useAuthStore } from '@/stores/auth-store' - -declare module 'axios' { - export interface AxiosRequestConfig { - skipBusinessError?: boolean - skipErrorHandler?: boolean - disableDuplicate?: boolean - } -} - -export type ApiRequestConfig = AxiosRequestConfig - -// ============================================================================ -// Axios Instance Configuration -// ============================================================================ - -// Base URL: empty string for same-origin API requests -const baseURL = '' - -// Create axios instance with default config -export const api = axios.create({ - baseURL, - withCredentials: true, // Include cookies in cross-origin requests - headers: { - 'Cache-Control': 'no-store', // Prevent caching - }, -}) - -// ============================================================================ -// Request Deduplication -// ============================================================================ - -// Deduplicate concurrent GET requests to the same URL -// Prevents multiple identical requests from being sent simultaneously -const inFlightGet = new Map>() -const originalGet = api.get.bind(api) - -api.get = ((url: string, config: ApiRequestConfig = {}) => { - const disableDuplicate = config.disableDuplicate - if (disableDuplicate) return originalGet(url, config) - - const params = config.params ? JSON.stringify(config.params) : '{}' - const key = `${url}?${params}` - - // Return existing in-flight request if available - if (inFlightGet.has(key)) return inFlightGet.get(key)! - - // Create new request and clean up after completion - const req = originalGet(url, config).finally(() => inFlightGet.delete(key)) - inFlightGet.set(key, req) - return req -}) as typeof api.get - -// ============================================================================ -// Response Interceptor -// ============================================================================ - -// Handle business logic errors and HTTP errors globally -api.interceptors.response.use( - (response) => { - const skipBusiness = response.config.skipBusinessError - - // Unified business response format: { success, message, data } - if ( - !skipBusiness && - response && - response.data && - typeof response.data.success === 'boolean' - ) { - if (!response.data.success) { - // Show error toast for business failures - const msg = response.data.message || t('Request failed') - toast.error(msg) - } - } - return response - }, - (error) => { - const skip = error?.config?.skipErrorHandler - const status = error?.response?.status - - if (status === 401) { - try { - useAuthStore.getState().auth.reset() - } catch { - /* empty */ - } - - if (!skip) { - toast.error(t('Session expired!')) - } - } else if (!skip) { - // Other errors: show error message from response or default - const msg = - error?.response?.data?.message || error?.message || t('Request failed') - toast.error(msg) - } - return Promise.reject(error) - } -) - -// ============================================================================ -// Common Headers Utility -// ============================================================================ - -/** - * Get user ID from localStorage - */ -function getUserId(): string | null { - try { - if (typeof window !== 'undefined') { - return window.localStorage.getItem('uid') - } - } catch { - /* empty */ - } - return null -} - -/** - * Get common request headers (for both axios and SSE requests) - */ -export function getCommonHeaders(): Record { - const headers: Record = { - 'Content-Type': 'application/json', - } - - const uid = getUserId() - if (uid) { - headers['New-Api-User'] = uid - } - - return headers -} +import { api } from '@/lib/http-client' + +export { + applyAuthBundle, + applyAuthRotation, + bootstrapAuthentication, + clearAuthentication, + getCommonHeaders, + getFreshAuthHeaders, + isAuthBundle, + refreshAuthentication, + AuthRotationError, +} from '@/lib/auth-session' +export type { AuthTokenRotation, RefreshOutcome } from '@/lib/auth-session' +export { api } +export type { ApiRequestConfig } from '@/lib/http-client' // ============================================================================ -// Request Interceptor -// ============================================================================ - -// Attach user ID header for all requests -api.interceptors.request.use((config) => { - const uid = getUserId() - if (uid) { - // Custom header for user identification - ;(config.headers as Record)['New-Api-User'] = uid - } - return config -}) - -// ============================================================================ -// Common API Functions -// ============================================================================ - -// ---------------------------------------------------------------------------- // User APIs -// ---------------------------------------------------------------------------- +// ============================================================================ -// Get current user info export async function getSelf() { const res = await api.get('/api/user/self', { - // Avoid global 401 toast during guards/preloads skipErrorHandler: true, }) return res.data } -// Get user available models export async function getUserModels(): Promise<{ success: boolean message?: string @@ -196,7 +53,6 @@ export async function getUserModels(): Promise<{ return res.data } -// Get user groups with descriptions and ratios export async function getUserGroups(): Promise<{ success: boolean message?: string @@ -206,17 +62,15 @@ export async function getUserGroups(): Promise<{ return res.data } -// ---------------------------------------------------------------------------- +// ============================================================================ // System APIs -// ---------------------------------------------------------------------------- +// ============================================================================ -// Get system status export async function getStatus() { const res = await api.get('/api/status') return res.data?.data as Record } -// Get system notice export async function getNotice(): Promise<{ success: boolean message?: string @@ -226,36 +80,43 @@ export async function getNotice(): Promise<{ return res.data } -// ---------------------------------------------------------------------------- +// ============================================================================ // 2FA Management APIs -// ---------------------------------------------------------------------------- +// ============================================================================ -// Get 2FA status export async function get2FAStatus() { const res = await api.get('/api/user/2fa/status') return res.data } -// Setup 2FA export async function setup2FA() { const res = await api.post('/api/user/2fa/setup') return res.data } -// Enable 2FA with verification code export async function enable2FA(code: string) { - const res = await api.post('/api/user/2fa/enable', { code }) + const res = await api.post( + '/api/user/2fa/enable', + { code }, + { acceptAuthRotation: true } + ) return res.data } -// Disable 2FA with verification code export async function disable2FA(code: string) { - const res = await api.post('/api/user/2fa/disable', { code }) + const res = await api.post( + '/api/user/2fa/disable', + { code }, + { acceptAuthRotation: true } + ) return res.data } -// Regenerate 2FA backup codes export async function regenerate2FABackupCodes(code: string) { - const res = await api.post('/api/user/2fa/backup_codes', { code }) + const res = await api.post( + '/api/user/2fa/backup_codes', + { code }, + { acceptAuthRotation: true } + ) return res.data } diff --git a/web/default/src/lib/auth-session-sync.ts b/web/default/src/lib/auth-session-sync.ts new file mode 100644 index 000000000000..1866acd5540f --- /dev/null +++ b/web/default/src/lib/auth-session-sync.ts @@ -0,0 +1,119 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +export type AuthSessionSyncEvent = { + kind: 'authenticated' | 'signed_out' + sid: string + source: string + nonce: string + timestamp: number +} + +const AUTH_SYNC_CHANNEL = 'new-api:auth-session' +const AUTH_SYNC_STORAGE_KEY = 'new-api:auth-session:event' + +function randomIdentifier(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID() + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +const authSyncSource = randomIdentifier() +let authSyncPublisher: BroadcastChannel | null = null + +function isAuthSessionSyncEvent(value: unknown): value is AuthSessionSyncEvent { + if (!value || typeof value !== 'object') return false + const event = value as Partial + return ( + (event.kind === 'authenticated' || event.kind === 'signed_out') && + typeof event.sid === 'string' && + event.sid.length > 0 && + typeof event.source === 'string' && + typeof event.nonce === 'string' && + typeof event.timestamp === 'number' + ) +} + +export function publishAuthSessionEvent( + kind: AuthSessionSyncEvent['kind'], + sid: string +): void { + if (typeof window === 'undefined' || !sid) return + const event: AuthSessionSyncEvent = { + kind, + sid, + source: authSyncSource, + nonce: randomIdentifier(), + timestamp: Date.now(), + } + + if (typeof BroadcastChannel !== 'undefined') { + authSyncPublisher ??= new BroadcastChannel(AUTH_SYNC_CHANNEL) + authSyncPublisher.postMessage(event) + return + } + + try { + window.localStorage.setItem(AUTH_SYNC_STORAGE_KEY, JSON.stringify(event)) + window.localStorage.removeItem(AUTH_SYNC_STORAGE_KEY) + } catch { + // Cross-tab synchronization is best-effort when storage is unavailable. + } +} + +export function subscribeAuthSessionEvents( + listener: (event: AuthSessionSyncEvent) => void +): () => void { + if (typeof window === 'undefined') return () => undefined + + const deliver = (value: unknown) => { + if ( + isAuthSessionSyncEvent(value) && + value.source !== authSyncSource && + Math.abs(Date.now() - value.timestamp) < 60_000 + ) { + listener(value) + } + } + + if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel(AUTH_SYNC_CHANNEL) + const handleMessage = (message: MessageEvent) => { + deliver(message.data) + } + channel.addEventListener('message', handleMessage) + return () => { + channel.removeEventListener('message', handleMessage) + channel.close() + } + } + + const handleStorage = (event: StorageEvent) => { + if (event.key !== AUTH_SYNC_STORAGE_KEY || !event.newValue) return + try { + deliver(JSON.parse(event.newValue)) + } catch { + // Ignore malformed same-origin storage events. + } + } + window.addEventListener('storage', handleStorage) + return () => { + window.removeEventListener('storage', handleStorage) + } +} diff --git a/web/default/src/lib/auth-session.test.ts b/web/default/src/lib/auth-session.test.ts new file mode 100644 index 000000000000..7e4f81f1cdb1 --- /dev/null +++ b/web/default/src/lib/auth-session.test.ts @@ -0,0 +1,266 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { afterEach, describe, test } from 'node:test' + +import { useAuthStore, type AuthBundle } from '../stores/auth-store' +import { + applyAuthRotation, + bootstrapAuthentication, + createRefreshRunner, + isAuthBundle, + type AuthRefreshRuntime, +} from './auth-session' + +const bundle: AuthBundle = { + access_token: 'access-token', + token_type: 'Bearer', + access_expires_at: Math.floor(Date.now() / 1000) + 600, + user: { + id: 42, + username: 'test-user', + role: 1, + }, + session: { + sid: 'session-a', + current: true, + login_method: 'password', + ip: '127.0.0.1', + user_agent: 'test', + created_at: 100, + last_active_at: 100, + expires_at: 1000, + }, +} + +afterEach(() => { + useAuthStore.getState().auth.reset('idle') +}) + +describe('authentication session coordination', () => { + test('bootstrap distinguishes a completed anonymous check from an active session', async () => { + useAuthStore.getState().auth.reset('complete') + assert.deepEqual(await bootstrapAuthentication(), { kind: 'anonymous' }) + + useAuthStore.getState().auth.setBundle(bundle) + assert.deepEqual(await bootstrapAuthentication(), { + kind: 'authenticated', + bundle, + }) + }) + + test('a session mismatch clears only local state and retries without the stale SID', async () => { + let expectedSID: string | undefined = bundle.session.sid + const requestedSIDs: Array = [] + const clears: Array<[boolean, string | undefined]> = [] + const accepted: AuthBundle[] = [] + const runtime: AuthRefreshRuntime = { + request: async (sid) => { + requestedSIDs.push(sid) + if (requestedSIDs.length === 1) { + return { + status: 409, + data: { code: 'AUTH_SESSION_MISMATCH' }, + } + } + return { status: 200, data: { success: true, data: bundle } } + }, + getExpectedSID: () => expectedSID, + parseBundle: (value) => (isAuthBundle(value) ? value : null), + acceptBundle: (acceptedBundle) => accepted.push(acceptedBundle), + clear: (synchronizeTabs, bootstrapState) => { + clears.push([synchronizeTabs, bootstrapState]) + expectedSID = undefined + }, + markTransient: () => undefined, + wait: async () => undefined, + } + + const outcome = await createRefreshRunner(runtime)() + + assert.equal(outcome.kind, 'authenticated') + assert.deepEqual(requestedSIDs, [bundle.session.sid, undefined]) + assert.deepEqual(clears, [[false, 'idle']]) + assert.deepEqual(accepted, [bundle]) + }) + + test('a rejected refresh confirms anonymous state and synchronizes sign-out', async () => { + const clears: Array<[boolean, string | undefined]> = [] + const runtime: AuthRefreshRuntime = { + request: async () => ({ status: 401 }), + getExpectedSID: () => bundle.session.sid, + parseBundle: () => null, + acceptBundle: () => undefined, + clear: (synchronizeTabs, bootstrapState) => { + clears.push([synchronizeTabs, bootstrapState]) + }, + markTransient: () => undefined, + wait: async () => undefined, + } + + assert.deepEqual(await createRefreshRunner(runtime)(), { + kind: 'anonymous', + }) + assert.deepEqual(clears, [[true, undefined]]) + }) + + test('a temporary refresh failure remains retryable without clearing the session', async () => { + let transientCount = 0 + let clearCount = 0 + const runtime: AuthRefreshRuntime = { + request: async () => ({ status: 503, error: new Error('unavailable') }), + getExpectedSID: () => bundle.session.sid, + parseBundle: () => null, + acceptBundle: () => undefined, + clear: () => { + clearCount += 1 + }, + markTransient: () => { + transientCount += 1 + }, + wait: async () => undefined, + } + + const outcome = await createRefreshRunner(runtime)() + + assert.equal(outcome.kind, 'transient_error') + assert.equal(clearCount, 0) + assert.equal(transientCount, 1) + }) + + test('an exhausted refresh race clears the unusable local session', async () => { + const requestedDelays: number[] = [] + const clears: Array<[boolean, string | undefined]> = [] + const runtime: AuthRefreshRuntime = { + request: async () => ({ + status: 409, + data: { code: 'AUTH_REFRESH_RACE' }, + }), + getExpectedSID: () => bundle.session.sid, + parseBundle: () => null, + acceptBundle: () => undefined, + clear: (synchronizeTabs, bootstrapState) => { + clears.push([synchronizeTabs, bootstrapState]) + }, + markTransient: () => undefined, + wait: async (delay) => { + requestedDelays.push(delay) + }, + } + + assert.deepEqual(await createRefreshRunner(runtime)(), { + kind: 'out_of_sync', + code: 'AUTH_REFRESH_RACE', + }) + assert.deepEqual(requestedDelays, [80, 200, 500]) + assert.deepEqual(clears, [[false, undefined]]) + }) + + test('an unexpected successful response is treated as out of sync', async () => { + let cleared = false + const runtime: AuthRefreshRuntime = { + request: async () => ({ status: 200, data: { success: true } }), + getExpectedSID: () => bundle.session.sid, + parseBundle: () => null, + acceptBundle: () => undefined, + clear: () => { + cleared = true + }, + markTransient: () => undefined, + wait: async () => undefined, + } + + assert.deepEqual(await createRefreshRunner(runtime)(), { + kind: 'out_of_sync', + code: 'AUTH_INVALID_REFRESH_RESPONSE', + }) + assert.equal(cleared, true) + }) + + test('a refresh response cannot restore credentials after a newer auth operation', async () => { + let current = true + let accepted = false + const runtime: AuthRefreshRuntime = { + request: async () => { + current = false + return { status: 200, data: { success: true, data: bundle } } + }, + getExpectedSID: () => bundle.session.sid, + parseBundle: (value) => (isAuthBundle(value) ? value : null), + acceptBundle: () => { + accepted = true + }, + clear: () => undefined, + markTransient: () => undefined, + wait: async () => undefined, + isCurrent: () => current, + } + + const outcome = await createRefreshRunner(runtime)() + + assert.equal(outcome.kind, 'transient_error') + assert.equal(accepted, false) + }) + + test('explicit rotations update only the current session', () => { + useAuthStore.getState().auth.setBundle(bundle) + applyAuthRotation({ + access_token: 'rotated-token', + token_type: 'Bearer', + access_expires_at: bundle.access_expires_at + 60, + session: { ...bundle.session, last_active_at: 200 }, + }) + + assert.equal(useAuthStore.getState().auth.accessToken, 'rotated-token') + assert.strictEqual(useAuthStore.getState().auth.user, bundle.user) + + assert.throws( + () => + applyAuthRotation({ + access_token: 'non-bearer-token', + token_type: 'Custom', + access_expires_at: bundle.access_expires_at + 120, + session: bundle.session, + }), + /Invalid authentication rotation response/ + ) + assert.throws( + () => + applyAuthRotation({ + access_token: 'non-current-token', + token_type: 'Bearer', + access_expires_at: bundle.access_expires_at + 120, + session: { ...bundle.session, current: false }, + }), + /Invalid authentication rotation response/ + ) + + assert.throws( + () => + applyAuthRotation({ + access_token: 'wrong-session-token', + token_type: 'Bearer', + access_expires_at: bundle.access_expires_at + 120, + session: { ...bundle.session, sid: 'session-b' }, + }), + /session mismatch/ + ) + assert.equal(useAuthStore.getState().auth.accessToken, 'rotated-token') + }) +}) diff --git a/web/default/src/lib/auth-session.ts b/web/default/src/lib/auth-session.ts new file mode 100644 index 000000000000..5fb7a2e9de29 --- /dev/null +++ b/web/default/src/lib/auth-session.ts @@ -0,0 +1,411 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import axios from 'axios' +import { t } from 'i18next' + +import { publishAuthSessionEvent } from '@/lib/auth-session-sync' +import { + useAuthStore, + type AuthBootstrapState, + type AuthBundle, + type AuthUser, + type LoginSession, +} from '@/stores/auth-store' + +export type RefreshOutcome = + | { kind: 'authenticated'; bundle: AuthBundle } + | { kind: 'anonymous' } + | { kind: 'transient_error'; error: unknown } + | { kind: 'out_of_sync'; code?: string } + +export interface AuthRefreshHTTPResponse { + status: number + data?: unknown + error?: unknown +} + +export interface AuthRefreshRuntime { + request: (expectedSID?: string) => Promise + getExpectedSID: () => string | undefined + parseBundle: (value: unknown) => AuthBundle | null + acceptBundle: (bundle: AuthBundle) => void + clear: (synchronizeTabs: boolean, bootstrapState?: AuthBootstrapState) => void + markTransient: () => void + wait: (delay: number) => Promise + isCurrent?: () => boolean +} + +export interface AuthTokenRotation { + access_token: string + token_type: string + access_expires_at: number + session: LoginSession +} + +export class AuthRotationError extends Error { + constructor(message: string) { + super(message) + this.name = 'AuthRotationError' + } +} + +const authClient = axios.create({ + baseURL: '', + withCredentials: true, + headers: { + 'Cache-Control': 'no-store', + }, +}) + +const refreshRaceDelays = [80, 200, 500] as const +let refreshPromise: Promise | null = null +let authEpoch = 0 + +class AuthRefreshSupersededError extends Error { + constructor() { + super('Authentication refresh was superseded') + this.name = 'AuthRefreshSupersededError' + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +function isAuthUser(value: unknown): value is AuthUser { + if (!isRecord(value)) return false + return ( + Number.isInteger(value.id) && + Number(value.id) > 0 && + typeof value.username === 'string' && + typeof value.role === 'number' + ) +} + +function isLoginSession(value: unknown): value is LoginSession { + if (!isRecord(value)) return false + return ( + typeof value.sid === 'string' && + value.sid.length > 0 && + typeof value.current === 'boolean' && + typeof value.login_method === 'string' && + typeof value.ip === 'string' && + typeof value.user_agent === 'string' && + typeof value.created_at === 'number' && + typeof value.last_active_at === 'number' && + typeof value.expires_at === 'number' + ) +} + +function hasValidTokenFields(value: Record): boolean { + return ( + typeof value.access_token === 'string' && + value.access_token.length > 0 && + typeof value.token_type === 'string' && + value.token_type.length > 0 && + typeof value.access_expires_at === 'number' && + Number.isFinite(value.access_expires_at) && + value.access_expires_at > 0 + ) +} + +export function isAuthBundle(value: unknown): value is AuthBundle { + if (!isRecord(value)) return false + return ( + hasValidTokenFields(value) && + isAuthUser(value.user) && + isLoginSession(value.session) + ) +} + +function isAuthTokenRotation(value: unknown): value is AuthTokenRotation { + return ( + isRecord(value) && + hasValidTokenFields(value) && + value.token_type === 'Bearer' && + isLoginSession(value.session) && + value.session.current + ) +} + +export function applyAuthBundle( + bundle: AuthBundle, + synchronizeTabs = true +): void { + const previousSID = useAuthStore.getState().auth.session?.sid + authEpoch += 1 + useAuthStore.getState().auth.setBundle(bundle) + if (synchronizeTabs && previousSID !== bundle.session.sid) { + publishAuthSessionEvent('authenticated', bundle.session.sid) + } +} + +export function applyAuthRotation(value: unknown): void { + if (!isAuthTokenRotation(value)) { + throw new AuthRotationError('Invalid authentication rotation response') + } + + const auth = useAuthStore.getState().auth + if (!auth.user || !auth.session) { + throw new AuthRotationError('Authentication rotation has no active session') + } + if (value.session.sid !== auth.session.sid) { + throw new AuthRotationError('Authentication rotation session mismatch') + } + + applyAuthBundle( + { + access_token: value.access_token, + token_type: value.token_type, + access_expires_at: value.access_expires_at, + session: value.session, + user: auth.user, + }, + false + ) +} + +export function clearAuthentication( + synchronizeTabs = true, + bootstrapState: AuthBootstrapState = 'complete' +): void { + const sid = useAuthStore.getState().auth.session?.sid + authEpoch += 1 + useAuthStore.getState().auth.reset(bootstrapState) + if (synchronizeTabs && sid) { + publishAuthSessionEvent('signed_out', sid) + } +} + +function waitForRefreshRace(delay: number): Promise { + return new Promise((resolve) => globalThis.setTimeout(resolve, delay)) +} + +export function createRefreshRunner( + runtime: AuthRefreshRuntime +): () => Promise { + const superseded = (): RefreshOutcome => ({ + kind: 'transient_error', + error: new AuthRefreshSupersededError(), + }) + const run = async ( + raceAttempt: number, + allowMismatchRetry: boolean + ): Promise => { + if (runtime.isCurrent && !runtime.isCurrent()) return superseded() + const response = await runtime.request(runtime.getExpectedSID()) + if (runtime.isCurrent && !runtime.isCurrent()) return superseded() + const responseData = isRecord(response.data) ? response.data : undefined + const code = + typeof responseData?.code === 'string' ? responseData.code : undefined + const bundle = runtime.parseBundle(responseData?.data) + if (responseData?.success === true && bundle) { + runtime.acceptBundle(bundle) + return { kind: 'authenticated', bundle } + } + + if (response.status === 409 && code === 'AUTH_REFRESH_RACE') { + const delay = refreshRaceDelays[raceAttempt] + if (delay !== undefined) { + await runtime.wait(delay) + return run(raceAttempt + 1, allowMismatchRetry) + } + runtime.clear(false) + return { kind: 'out_of_sync', code } + } + + if (response.status === 409 && code === 'AUTH_SESSION_MISMATCH') { + if (allowMismatchRetry) { + runtime.clear(false, 'idle') + return run(0, false) + } + runtime.clear(false) + return { kind: 'out_of_sync', code } + } + + if (response.status === 401) { + runtime.clear(true) + return { kind: 'anonymous' } + } + + if (!response.status || response.status >= 500) { + runtime.markTransient() + return { + kind: 'transient_error', + error: response.error ?? response.data, + } + } + + runtime.clear(false) + return { + kind: 'out_of_sync', + code: code ?? 'AUTH_INVALID_REFRESH_RESPONSE', + } + } + + return () => run(0, true) +} + +async function requestRefresh( + expectedSID?: string +): Promise { + try { + const response = await authClient.post( + '/api/user/auth/refresh', + undefined, + { + headers: expectedSID ? { 'X-Auth-Session': expectedSID } : undefined, + } + ) + return { status: response.status, data: response.data } + } catch (error: unknown) { + if (!axios.isAxiosError(error)) return { status: 0, error } + return { + status: error.response?.status ?? 0, + data: error.response?.data, + error, + } + } +} + +function runRefresh(refreshEpoch: number): Promise { + return createRefreshRunner({ + request: requestRefresh, + getExpectedSID: () => useAuthStore.getState().auth.session?.sid, + parseBundle: (value) => (isAuthBundle(value) ? value : null), + acceptBundle: (bundle) => applyAuthBundle(bundle, false), + clear: (synchronizeTabs, bootstrapState) => { + if (!synchronizeTabs && bootstrapState === 'idle') { + useAuthStore.getState().auth.reset('idle') + return + } + clearAuthentication(synchronizeTabs, bootstrapState) + }, + markTransient: () => useAuthStore.getState().auth.setBootstrapState('idle'), + wait: waitForRefreshRace, + isCurrent: () => authEpoch === refreshEpoch, + })() +} + +async function performRefreshWithBrowserLock( + refreshEpoch: number +): Promise { + try { + if (typeof navigator === 'undefined' || !navigator.locks) { + return runRefresh(refreshEpoch) + } + return navigator.locks.request( + 'new-api:auth-refresh', + { mode: 'exclusive' }, + () => runRefresh(refreshEpoch) + ) + } catch (error: unknown) { + useAuthStore.getState().auth.setBootstrapState('idle') + return { kind: 'transient_error', error } + } +} + +export function refreshAuthentication(): Promise { + if (!refreshPromise) { + const refreshEpoch = authEpoch + refreshPromise = performRefreshWithBrowserLock(refreshEpoch).finally(() => { + refreshPromise = null + }) + } + return refreshPromise +} + +function currentValidAuthBundle(): AuthBundle | null { + const auth = useAuthStore.getState().auth + if ( + !auth.user || + !auth.accessToken || + !auth.accessExpiresAt || + !auth.session || + auth.accessExpiresAt <= Math.floor(Date.now() / 1000) + ) { + return null + } + return { + access_token: auth.accessToken, + token_type: 'Bearer', + access_expires_at: auth.accessExpiresAt, + user: auth.user, + session: auth.session, + } +} + +export async function bootstrapAuthentication(): Promise { + const bundle = currentValidAuthBundle() + if (bundle) { + useAuthStore.getState().auth.setBootstrapState('complete') + return { kind: 'authenticated', bundle } + } + + const auth = useAuthStore.getState().auth + const hasStaleSession = Boolean(auth.user && auth.session) + if (auth.bootstrapState === 'complete' && !hasStaleSession) { + return { kind: 'anonymous' } + } + + auth.setBootstrapState('checking') + return refreshAuthentication() +} + +export function getCommonHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + } + const accessToken = useAuthStore.getState().auth.accessToken + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}` + } + return headers +} + +export async function getFreshAuthHeaders(): Promise> { + const auth = useAuthStore.getState().auth + const refreshBefore = Math.floor(Date.now() / 1000) + 60 + if ( + auth.accessToken && + auth.accessExpiresAt && + auth.accessExpiresAt > refreshBefore + ) { + return getCommonHeaders() + } + + const outcome = await refreshAuthentication() + if (outcome.kind === 'authenticated') { + return getCommonHeaders() + } + + const current = useAuthStore.getState().auth + if ( + current.accessToken && + current.accessExpiresAt && + current.accessExpiresAt > Math.floor(Date.now() / 1000) + ) { + return getCommonHeaders() + } + + if (outcome.kind === 'transient_error') { + throw new Error(t('Request failed'), { cause: outcome.error }) + } + throw new Error(t('Session expired!')) +} diff --git a/web/default/src/lib/http-client.ts b/web/default/src/lib/http-client.ts new file mode 100644 index 000000000000..9e3efa7c16dd --- /dev/null +++ b/web/default/src/lib/http-client.ts @@ -0,0 +1,140 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import axios, { type AxiosRequestConfig } from 'axios' +import { t } from 'i18next' +import { toast } from 'sonner' + +import { + applyAuthRotation, + clearAuthentication, + refreshAuthentication, +} from '@/lib/auth-session' +import { useAuthStore } from '@/stores/auth-store' + +declare module 'axios' { + export interface AxiosRequestConfig { + skipBusinessError?: boolean + skipErrorHandler?: boolean + disableDuplicate?: boolean + skipAuthRefresh?: boolean + authRetry?: boolean + acceptAuthRotation?: boolean + } +} + +export type ApiRequestConfig = AxiosRequestConfig + +export const api = axios.create({ + baseURL: '', + withCredentials: true, + headers: { + 'Cache-Control': 'no-store', + }, +}) + +const inFlightGet = new Map>() +const originalGet = api.get.bind(api) + +api.get = ((url: string, config: ApiRequestConfig = {}) => { + if (config.disableDuplicate) return originalGet(url, config) + + const params = config.params ? JSON.stringify(config.params) : '{}' + const sessionSID = useAuthStore.getState().auth.session?.sid || 'anonymous' + const key = `${sessionSID}:${url}?${params}` + const existingRequest = inFlightGet.get(key) + if (existingRequest) return existingRequest + + const request = originalGet(url, config).finally(() => { + inFlightGet.delete(key) + }) + inFlightGet.set(key, request) + return request +}) as typeof api.get + +function redirectToSignIn(): void { + if ( + typeof window !== 'undefined' && + window.location.pathname !== '/sign-in' + ) { + window.location.replace('/sign-in') + } +} + +api.interceptors.response.use( + (response) => { + if (response.config.acceptAuthRotation && response.data?.success === true) { + applyAuthRotation(response.data.data) + } + + if ( + !response.config.skipBusinessError && + typeof response.data?.success === 'boolean' && + !response.data.success + ) { + toast.error(response.data.message || t('Request failed')) + } + return response + }, + async (error) => { + const config = error?.config as ApiRequestConfig | undefined + const skipErrorHandler = config?.skipErrorHandler + const status = error?.response?.status + + if (status === 401) { + if (config && !config.skipAuthRefresh && !config.authRetry) { + config.authRetry = true + const outcome = await refreshAuthentication() + if (outcome.kind === 'authenticated') { + const token = useAuthStore.getState().auth.accessToken + if (token) { + config.headers = { + ...config.headers, + Authorization: `Bearer ${token}`, + } + } + return api.request(config) + } + + if (outcome.kind === 'anonymous' || outcome.kind === 'out_of_sync') { + if (!skipErrorHandler) toast.error(t('Session expired!')) + redirectToSignIn() + } + } else if (config?.authRetry) { + clearAuthentication(false) + if (!skipErrorHandler) toast.error(t('Session expired!')) + redirectToSignIn() + } else if (!skipErrorHandler) { + toast.error(t('Session expired!')) + } + } else if (!skipErrorHandler) { + const message = + error?.response?.data?.message || error?.message || t('Request failed') + toast.error(message) + } + throw error + } +) + +api.interceptors.request.use((config) => { + const accessToken = useAuthStore.getState().auth.accessToken + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}` + } + return config +}) diff --git a/web/default/src/lib/oauth.ts b/web/default/src/lib/oauth.ts index a94797349b55..3432d13a788f 100644 --- a/web/default/src/lib/oauth.ts +++ b/web/default/src/lib/oauth.ts @@ -16,8 +16,6 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { api } from './api' - // ============================================================================ // OAuth URL Builders // ============================================================================ @@ -68,77 +66,3 @@ export function buildOIDCOAuthUrl( export function buildLinuxDOOAuthUrl(clientId: string, state: string): string { return `https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${clientId}&state=${state}` } - -// ============================================================================ -// OAuth Helper Functions -// ============================================================================ - -/** - * Get OAuth state token - * Includes affiliate code from localStorage if available - */ -export async function getOAuthState(): Promise { - try { - let path = '/api/oauth/state' - const affCode = localStorage.getItem('aff') - if (affCode && affCode.length > 0) { - path += `?aff=${affCode}` - } - const res = await api.get(path) - if (res.data.success) { - return res.data.data - } - return null - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to get OAuth state:', error) - return null - } -} - -/** - * Handle GitHub OAuth binding/login - */ -export async function handleGitHubOAuth(clientId: string): Promise { - const state = await getOAuthState() - if (!state) return - - const url = buildGitHubOAuthUrl(clientId, state) - window.open(url, '_blank') -} - -/** - * Handle Discord OAuth binding/login - */ -export async function handleDiscordOAuth(clientId: string): Promise { - const state = await getOAuthState() - if (!state) return - - const url = buildDiscordOAuthUrl(clientId, state) - window.open(url, '_blank') -} - -/** - * Handle OIDC OAuth binding/login - */ -export async function handleOIDCOAuth( - authUrl: string, - clientId: string -): Promise { - const state = await getOAuthState() - if (!state) return - - const url = buildOIDCOAuthUrl(authUrl, clientId, state) - window.open(url, '_blank') -} - -/** - * Handle LinuxDO OAuth binding/login - */ -export async function handleLinuxDOOAuth(clientId: string): Promise { - const state = await getOAuthState() - if (!state) return - - const url = buildLinuxDOOAuthUrl(clientId, state) - window.open(url, '_blank') -} diff --git a/web/default/src/lib/secure-verification.ts b/web/default/src/lib/secure-verification.ts index 3cc771dd8af3..84439cb910ef 100644 --- a/web/default/src/lib/secure-verification.ts +++ b/web/default/src/lib/secure-verification.ts @@ -42,6 +42,11 @@ export function isVerificationRequiredError( 'VERIFICATION_REQUIRED', 'VERIFICATION_EXPIRED', 'VERIFICATION_INVALID', + 'SECURITY_PROOF_REQUIRED', + 'SECURITY_PROOF_EXPIRED', + 'SECURITY_PROOF_INVALID', + 'SECURITY_PROOF_SCOPE_MISMATCH', + 'SECURITY_PROOF_METHOD_MISMATCH', ]) return verificationCodes.has(code) diff --git a/web/default/src/main.tsx b/web/default/src/main.tsx index caf9307c1214..b2cf827c1ece 100644 --- a/web/default/src/main.tsx +++ b/web/default/src/main.tsx @@ -34,7 +34,6 @@ import { applyFaviconToDom } from '@/lib/dom-utils' import '@/lib/dayjs' import { initializeFrontendCache } from '@/lib/frontend-cache' import { handleServerError } from '@/lib/handle-server-error' -import { useAuthStore } from '@/stores/auth-store' import { DirectionProvider } from './context/direction-provider' import { FontProvider } from './context/font-provider' @@ -85,12 +84,6 @@ const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error) => { if (error instanceof AxiosError) { - if (error.response?.status === 401) { - toast.error(i18next.t('Session expired!')) - useAuthStore.getState().auth.reset() - const redirect = `${router.history.location.href}` - router.navigate({ to: '/sign-in', search: { redirect } }) - } if (error.response?.status === 500) { toast.error(i18next.t('Internal Server Error!')) router.navigate({ to: '/500' }) @@ -116,7 +109,10 @@ declare module '@tanstack/react-router' { } // Render the app -const rootElement = document.getElementById('root')! +const rootElement = document.querySelector('#root') +if (!rootElement) { + throw new Error('Root element not found') +} // Set document.title and favicon from cached status, then refresh from network ;(function initSystemBranding() { try { diff --git a/web/default/src/routes/(auth)/oauth.tsx b/web/default/src/routes/(auth)/oauth.tsx index 6256017cb426..15943db0f7b7 100644 --- a/web/default/src/routes/(auth)/oauth.tsx +++ b/web/default/src/routes/(auth)/oauth.tsx @@ -22,8 +22,7 @@ import { useEffect } from 'react' import { toast } from 'sonner' import { wechatLoginByCode } from '@/features/auth/api' -import { getSelf } from '@/lib/api' -import { useAuthStore, type AuthUser } from '@/stores/auth-store' +import { applyAuthBundle, isAuthBundle } from '@/lib/api' function OAuthComponent() { const navigate = useNavigate() @@ -38,14 +37,13 @@ function OAuthComponent() { ;(async () => { try { if (search?.provider === 'wechat' && search.code) { - await wechatLoginByCode(search.code) - } - const res = await getSelf() - if (res?.success) { - useAuthStore.getState().auth.setUser(res.data as AuthUser) - const target = search?.redirect || '/dashboard' - navigate({ to: target, replace: true }) - return + const res = await wechatLoginByCode(search.code) + if (res?.success && isAuthBundle(res.data)) { + applyAuthBundle(res.data) + const target = search?.redirect || '/dashboard' + navigate({ to: target, replace: true }) + return + } } } catch { /* empty */ diff --git a/web/default/src/routes/__root.tsx b/web/default/src/routes/__root.tsx index 3ed4fbb61ee0..8b89bb0e79b4 100644 --- a/web/default/src/routes/__root.tsx +++ b/web/default/src/routes/__root.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { type QueryClient } from '@tanstack/react-query' +import { useQueryClient, type QueryClient } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { createRootRouteWithContext, @@ -34,8 +34,16 @@ import { GeneralError } from '@/features/errors/general-error' import { NotFoundError } from '@/features/errors/not-found-error' import { getSetupStatus } from '@/features/setup/api' import { useSystemConfig } from '@/hooks/use-system-config' +import { + bootstrapAuthentication, + clearAuthentication, +} from '@/lib/auth-session' +import { subscribeAuthSessionEvents } from '@/lib/auth-session-sync' +import { useAuthStore } from '@/stores/auth-store' function RootComponent() { + const queryClient = useQueryClient() + // Load system configuration (logo, system name, etc.) from backend useSystemConfig({ autoLoad: true }) @@ -46,6 +54,40 @@ function RootComponent() { } }, []) + useEffect( + () => + useAuthStore.subscribe((state, previousState) => { + const sid = state.auth.session?.sid + const previousSID = previousState.auth.session?.sid + if (sid !== previousSID) { + queryClient.clear() + } + }), + [queryClient] + ) + + useEffect( + () => + subscribeAuthSessionEvents((event) => { + const currentSID = useAuthStore.getState().auth.session?.sid + + if (event.kind === 'authenticated') { + if (event.sid === currentSID) return + if (currentSID) { + clearAuthentication(false) + } + window.location.reload() + return + } + + if (currentSID && event.sid === currentSID) { + clearAuthentication(false) + window.location.replace('/sign-in') + } + }), + [] + ) + return ( @@ -101,30 +143,29 @@ export const Route = createRootRouteWithContext<{ const pathname = location?.pathname || '' const needsSetupCheck = !setupStatusChecked && !pathname.startsWith('/setup') - - // 用户信息已通过 auth-store 从 localStorage 恢复 - // 如果 auth.user 存在,说明用户已登录(有缓存的用户数据) - // 如果 auth.user 为 null,说明用户未登录,直接让 _authenticated 路由处理重定向 - // 不再调用 getSelf() API,避免不必要的网络请求和等待 + const authBootstrap = bootstrapAuthentication() // 只检查 setup 状态(如果需要) if (needsSetupCheck) { - const status = await getSetupStatus().catch((error) => { - if (import.meta.env.DEV) { - // eslint-disable-next-line no-console - console.warn('[root.beforeLoad] setup status check failed', error) - } - return null - }) + const [status] = await Promise.all([ + getSetupStatus().catch((error) => { + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.warn('[root.beforeLoad] setup status check failed', error) + } + return null + }), + authBootstrap, + ]) if (status?.success && status.data && !status.data.status) { throw redirect({ to: '/setup' }) } setupStatusChecked = true setSetupStatusCache(true) + } else { + await authBootstrap } - // 用户认证状态完全依赖 localStorage 缓存 - // 如果用户有有效 session 但 localStorage 被清空,会被重定向到登录页重新登录 }, component: RootComponent, notFoundComponent: NotFoundError, diff --git a/web/default/src/routes/_authenticated/route.tsx b/web/default/src/routes/_authenticated/route.tsx index 64ce6f32eb34..e0d0eb461598 100644 --- a/web/default/src/routes/_authenticated/route.tsx +++ b/web/default/src/routes/_authenticated/route.tsx @@ -19,45 +19,18 @@ For commercial licensing, please contact support@quantumnous.com import { createFileRoute, redirect } from '@tanstack/react-router' import { AuthenticatedLayout } from '@/components/layout' -import { getSelf } from '@/lib/api' import { useAuthStore } from '@/stores/auth-store' -// 内存中的验证标记,避免同一会话中重复验证 -let sessionVerified = false - export const Route = createFileRoute('/_authenticated')({ - beforeLoad: async ({ location }) => { + beforeLoad: ({ location }) => { const { auth } = useAuthStore.getState() - // 如果本地没有用户信息,直接跳转登录页 - if (!auth.user) { + if (!auth.user || !auth.accessToken) { throw redirect({ to: '/sign-in', search: { redirect: location.href }, }) } - - // 本地有用户信息,但需要验证 session 是否有效(每个会话只验证一次) - if (!sessionVerified) { - // 仅 401 视为 session 失效;网络错误/超时/5xx 返回 null 放行,下次导航重验 - const res = await getSelf().catch((err: unknown) => - (err as { response?: { status?: number } })?.response?.status === 401 - ? { success: false } - : null - ) - if (res?.success && res.data) { - // 验证成功,更新用户信息(可能有变化) - auth.setUser(res.data) - sessionVerified = true - } else if (res) { - // 验证失败,清除本地缓存并跳转登录页 - auth.reset() - throw redirect({ - to: '/sign-in', - search: { redirect: location.href }, - }) - } - } }, component: AuthenticatedLayout, }) diff --git a/web/default/src/routes/oauth/$provider.tsx b/web/default/src/routes/oauth/$provider.tsx index b35bfdaffe60..8f22e48a59fa 100644 --- a/web/default/src/routes/oauth/$provider.tsx +++ b/web/default/src/routes/oauth/$provider.tsx @@ -24,18 +24,30 @@ import { } from '@tanstack/react-router' import type { AxiosRequestConfig } from 'axios' import i18next from 'i18next' -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import { toast } from 'sonner' import { OAuthCallbackScreen } from '@/features/auth/components/oauth-callback-screen' -import { OAUTH_BIND_STORAGE_KEY } from '@/features/auth/constants' -import { api, getSelf } from '@/lib/api' -import { useAuthStore, type AuthUser } from '@/stores/auth-store' +import { + OAUTH_BIND_CALLBACK_MESSAGE, + OAUTH_BIND_RESULT_MESSAGE, + TELEGRAM_BIND_RESULT_MESSAGE, +} from '@/features/auth/constants' +import { startOAuthBindResponseDeadline } from '@/features/auth/lib/oauth-bind-window' +import { api, applyAuthBundle, isAuthBundle } from '@/lib/api' type OAuthRequestConfig = AxiosRequestConfig & { skipBusinessError?: boolean } +interface OAuthBindingResult { + type: typeof OAUTH_BIND_RESULT_MESSAGE + provider: string + state: string + success: boolean + message?: string +} + function OAuthCallback() { const navigate = useNavigate() const { provider } = useParams({ from: '/oauth/$provider' }) as { @@ -44,201 +56,157 @@ function OAuthCallback() { const search = useSearch({ from: '/oauth/$provider' }) as { code?: string state?: string + error?: string + error_description?: string redirect?: string + telegram_bind?: string + flow_token?: string } - const [mode, setMode] = useState<'login' | 'bind'>(() => { - if (typeof window === 'undefined') return 'login' - return window.opener ? 'bind' : 'login' - }) + const mode: 'login' | 'bind' = + typeof window !== 'undefined' && window.opener ? 'bind' : 'login' useEffect(() => { if (typeof window === 'undefined') return - // eslint-disable-next-line react-hooks/set-state-in-effect - setMode(window.opener ? 'bind' : 'login') - }, []) - - useEffect(() => { - ;(async () => { - const safeNavigate = (target: string) => { - navigate({ to: target as never, replace: true }) - if (typeof window !== 'undefined') { - setTimeout(() => { - const normalizedTarget = target.startsWith('/') - ? target - : `/${target}` - const currentPath = - window.location.pathname + window.location.search - if ( - currentPath !== normalizedTarget && - currentPath !== `${normalizedTarget}/` - ) { - window.location.replace(target) - } - }, 100) - } - } - if (!search?.code) { - toast.error(i18next.t('Missing code')) - safeNavigate('/sign-in') + const code = search.code ?? '' + const state = search.state ?? '' + if (mode === 'bind') { + const opener = window.opener + if (!opener || opener.closed) { + toast.error(i18next.t('OAuth binding window is no longer available')) return } - const isBindingFlow = - typeof window !== 'undefined' ? Boolean(window.opener) : mode === 'bind' - if (isBindingFlow && mode !== 'bind') { - setMode('bind') - } else if (!isBindingFlow && mode !== 'login') { - setMode('login') - } - const notifyBindingResult = (status: 'success' | 'error') => { - if (typeof window === 'undefined') return - try { - window.localStorage.setItem( - OAUTH_BIND_STORAGE_KEY, - JSON.stringify({ - provider, - status, - timestamp: Date.now(), - }) - ) - } catch (_error) { - // ignore storage write failures - void _error - } - } - const closeBindingWindow = () => { - if (typeof window === 'undefined') return + if ( + provider === 'telegram' && + search.telegram_bind === 'success' && + search.flow_token + ) { + opener.postMessage( + { + type: TELEGRAM_BIND_RESULT_MESSAGE, + flow_token: search.flow_token, + success: true, + }, + window.location.origin + ) window.close() - setTimeout(() => { - if (!window.closed) { - window.location.replace('/_authenticated/profile/') - } - }, 200) + return } - const finalizeLogin = async (): Promise => { - try { - const selfResponse = (await getSelf()) as { - success?: boolean - data?: AuthUser | null - } - if (selfResponse?.success && selfResponse.data) { - useAuthStore.getState().auth.setUser(selfResponse.data) - try { - if ( - typeof window !== 'undefined' && - selfResponse.data?.id != null - ) { - window.localStorage.setItem('uid', String(selfResponse.data.id)) - } - } catch (_error) { - void _error - } - return true - } - } catch (_error) { - void _error + let cancelResultTimeout: () => void = () => undefined + let delayedClose: number | undefined + const handleBindingResult = (event: MessageEvent) => { + if ( + event.origin !== window.location.origin || + event.source !== opener + ) { + return } - return false - } - - const redirectAfterLogin = (target?: string) => { - const to = target || search?.redirect || '/dashboard' - safeNavigate(to) - toast.success(i18next.t('Signed in successfully!')) + const result = event.data as Partial | null + if ( + !result || + result.type !== OAUTH_BIND_RESULT_MESSAGE || + result.provider !== provider || + result.state !== state + ) { + return + } + cancelResultTimeout() + if (result.success) { + toast.success(i18next.t('Binding successful!')) + window.close() + return + } + toast.error(result.message || i18next.t('OAuth failed')) + delayedClose = window.setTimeout(() => window.close(), 1500) } - const handleBindingFailure = (message: string) => { - notifyBindingResult('error') - toast.error(message) + window.addEventListener('message', handleBindingResult) + cancelResultTimeout = startOAuthBindResponseDeadline(() => { + toast.error(i18next.t('OAuth binding timed out. Please try again.')) + delayedClose = window.setTimeout(() => window.close(), 1500) + }) + opener.postMessage( + { + type: OAUTH_BIND_CALLBACK_MESSAGE, + provider, + code, + state, + error: search.error, + errorDescription: search.error_description, + }, + window.location.origin + ) + return () => { + window.removeEventListener('message', handleBindingResult) + cancelResultTimeout() + if (delayedClose !== undefined) window.clearTimeout(delayedClose) } - - const handleLoginFailure = async (message: string) => { - if (await finalizeLogin()) { - redirectAfterLogin() - return + } + + const safeNavigate = (target: string) => { + navigate({ to: target as never, replace: true }) + setTimeout(() => { + const normalizedTarget = target.startsWith('/') ? target : `/${target}` + const currentPath = window.location.pathname + window.location.search + if ( + currentPath !== normalizedTarget && + currentPath !== `${normalizedTarget}/` + ) { + window.location.replace(target) } - toast.error(message) - safeNavigate('/sign-in') - } + }, 100) + } + if (!code && !search.error) { + toast.error(i18next.t('Missing code')) + safeNavigate('/sign-in') + return + } + + void (async () => { try { const config: OAuthRequestConfig = { - params: { code: search.code, state: search.state }, + params: { + code: code || undefined, + state, + error: search.error, + error_description: search.error_description, + }, skipBusinessError: true, } - const res = await api.get(`/api/oauth/${provider}`, config) - if (res?.data?.success) { - const { message } = res.data - const loginUser = (res.data?.data ?? null) as AuthUser | null - // Check if this is a bind operation - if (message === 'bind') { - toast.success(i18next.t('Binding successful!')) - notifyBindingResult('success') - if (isBindingFlow) { - // Close the callback window if we opened a new tab for binding - closeBindingWindow() - } else { - safeNavigate('/_authenticated/profile/') - } - return - } - // Otherwise it's a login, use payload user if available - if (loginUser) { - useAuthStore.getState().auth.setUser(loginUser) - try { - if (typeof window !== 'undefined' && loginUser.id != null) { - window.localStorage.setItem('uid', String(loginUser.id)) - } - } catch (_error) { - void _error - } - redirectAfterLogin() - return - } - if (await finalizeLogin()) { - redirectAfterLogin() - return - } - toast.error(res?.data?.message || i18next.t('OAuth failed')) - safeNavigate('/sign-in') + const response = await api.get(`/api/oauth/${provider}`, config) + if (response.data?.success && isAuthBundle(response.data?.data)) { + applyAuthBundle(response.data.data) + safeNavigate(search.redirect || '/dashboard') + toast.success(i18next.t('Signed in successfully!')) return } - const message = res?.data?.message || 'OAuth failed' - if (!res?.data?.success && !isBindingFlow) { - // When logging in with an already bound GitHub account, backend may return this message - if (message === '该 GitHub 账户已被绑定') { - if (await finalizeLogin()) { - redirectAfterLogin() - return - } - } - } - if (isBindingFlow) { - handleBindingFailure(message) - } else { - await handleLoginFailure(message) - } - return - } catch (error) { - const message = ((error && - typeof error === 'object' && - 'response' in error && - (error as { response?: { data?: { message?: string } } }).response - ?.data?.message) ?? - (error instanceof Error ? error.message : undefined) ?? - 'OAuth failed') as string - - if (isBindingFlow) { - handleBindingFailure(message) - return - } - await handleLoginFailure(message) - return + toast.error(response.data?.message || i18next.t('OAuth failed')) + } catch (error: unknown) { + const responseMessage = ( + error as { response?: { data?: { message?: string } } } + ).response?.data?.message + toast.error( + responseMessage || + (error instanceof Error ? error.message : i18next.t('OAuth failed')) + ) } + safeNavigate('/sign-in') })() - }, [mode, navigate, provider, search]) + }, [ + mode, + navigate, + provider, + search.code, + search.error, + search.error_description, + search.flow_token, + search.redirect, + search.state, + search.telegram_bind, + ]) return } diff --git a/web/default/src/stores/auth-store.ts b/web/default/src/stores/auth-store.ts index 49165c0e9099..5c8b76dea1a7 100644 --- a/web/default/src/stores/auth-store.ts +++ b/web/default/src/stores/auth-store.ts @@ -43,66 +43,103 @@ export interface AuthUser { aff_history_quota?: number inviter_id?: number github_id?: string + discord_id?: string oidc_id?: string wechat_id?: string telegram_id?: string linux_do_id?: string + language?: string setting?: Record | string stripe_customer?: string sidebar_modules?: string permissions?: UserPermissions } +export interface LoginSession { + sid: string + current: boolean + login_method: string + ip: string + user_agent: string + created_at: number + last_active_at: number + expires_at: number +} + +export interface AuthBundle { + access_token: string + token_type: 'Bearer' | string + access_expires_at: number + user: AuthUser + session: LoginSession +} + +export type AuthBootstrapState = 'idle' | 'checking' | 'complete' + interface AuthState { auth: { user: AuthUser | null + accessToken: string | null + accessExpiresAt: number | null + session: LoginSession | null + pending2FAFlowToken: string | null + bootstrapState: AuthBootstrapState + setBundle: (bundle: AuthBundle) => void setUser: (user: AuthUser | null) => void - reset: () => void + setPending2FAFlowToken: (flowToken: string | null) => void + setBootstrapState: (bootstrapState: AuthBootstrapState) => void + reset: (bootstrapState?: AuthBootstrapState) => void } } -export const useAuthStore = create()((set) => { - // Restore user info from localStorage - const initUser = (() => { - try { - if (typeof window !== 'undefined') { - const saved = window.localStorage.getItem('user') - return saved ? JSON.parse(saved) : null - } - } catch { - // Clear dirty data when parsing fails - if (typeof window !== 'undefined') { - window.localStorage.removeItem('user') - } - } - return null - })() - - return { - auth: { - user: initUser, - setUser: (user) => - set((state) => { - // Persist user to localStorage - if (typeof window !== 'undefined') { - if (user) { - window.localStorage.setItem('user', JSON.stringify(user)) - } else { - window.localStorage.removeItem('user') - } - } - return { ...state, auth: { ...state.auth, user } } - }), - reset: () => - set((state) => { - if (typeof window !== 'undefined') { - window.localStorage.removeItem('user') - } - return { - ...state, - auth: { ...state.auth, user: null }, - } - }), - }, - } -}) +export const useAuthStore = create()((set) => ({ + auth: { + user: null, + accessToken: null, + accessExpiresAt: null, + session: null, + pending2FAFlowToken: null, + bootstrapState: 'idle', + setBundle: (bundle) => + set((state) => ({ + ...state, + auth: { + ...state.auth, + user: bundle.user, + accessToken: bundle.access_token, + accessExpiresAt: bundle.access_expires_at, + session: bundle.session, + pending2FAFlowToken: null, + bootstrapState: 'complete', + }, + })), + setUser: (user) => + set((state) => ({ + ...state, + auth: { ...state.auth, user }, + })), + setPending2FAFlowToken: (pending2FAFlowToken) => + set((state) => ({ + ...state, + auth: { ...state.auth, pending2FAFlowToken }, + })), + setBootstrapState: (bootstrapState) => + set((state) => ({ + ...state, + auth: { ...state.auth, bootstrapState }, + })), + reset: (bootstrapState = 'complete') => + set((state) => ({ + ...state, + auth: { + ...state.auth, + user: null, + accessToken: null, + accessExpiresAt: null, + session: null, + pending2FAFlowToken: null, + bootstrapState, + }, + })), + }, +})) From 99b84b7bb3306032999b3e597f357d109ae7131d Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 19 Jul 2026 20:54:16 +0800 Subject: [PATCH 2/5] feat(auth): harden session issuance and distributed enforcement --- .env.example | 15 + README.en.md | 22 +- README.fr.md | 22 +- README.ja.md | 22 +- README.md | 22 +- README.zh_CN.md | 22 +- README.zh_TW.md | 22 +- common/constants.go | 16 + common/init.go | 39 ++ common/user_session_test.go | 60 +++ controller/auth_session_test.go | 88 ++++ controller/user.go | 4 +- docker-compose.yml | 6 + docs/authentication.md | 55 ++- docs/openapi/api.json | 169 ++++++- main.go | 4 + middleware/email-verification-rate-limit.go | 33 +- middleware/model-rate-limit.go | 9 +- middleware/rate-limit.go | 197 ++++---- middleware/rate_limit_test.go | 223 +++++++++ model/user_session.go | 435 ++++++++++++++---- model/user_session_test.go | 388 +++++++++++++++- service/auth_cleanup.go | 15 + service/auth_session.go | 22 +- service/auth_session_test.go | 315 ++++++++++++- trusted_proxies.go | 35 ++ trusted_proxies_test.go | 73 +++ .../features/auth/otp/components/otp-form.tsx | 3 + .../sign-in/components/user-auth-form.tsx | 8 +- .../auth/sign-up/components/sign-up-form.tsx | 5 +- web/default/src/i18n/locales/en.json | 2 + web/default/src/i18n/locales/fr.json | 2 + web/default/src/i18n/locales/ja.json | 2 + web/default/src/i18n/locales/ru.json | 2 + web/default/src/i18n/locales/vi.json | 2 + web/default/src/i18n/locales/zh-TW.json | 2 + web/default/src/i18n/locales/zh.json | 2 + web/default/src/i18n/static-keys.ts | 2 + web/default/src/lib/handle-server-error.ts | 8 + web/default/src/lib/http-client.ts | 16 +- .../src/lib/server-error-message.test.ts | 40 ++ web/default/src/lib/server-error-message.ts | 49 ++ web/default/src/routes/(auth)/oauth.tsx | 12 +- web/default/src/routes/oauth/$provider.tsx | 21 +- 44 files changed, 2264 insertions(+), 247 deletions(-) create mode 100644 common/user_session_test.go create mode 100644 middleware/rate_limit_test.go create mode 100644 trusted_proxies.go create mode 100644 trusted_proxies_test.go create mode 100644 web/default/src/lib/server-error-message.test.ts create mode 100644 web/default/src/lib/server-error-message.ts diff --git a/.env.example b/.env.example index ffea953c46b7..977a4826b5d7 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,11 @@ # TLS / HTTP 跳过验证设置 # TLS_INSECURE_SKIP_VERIFY=false +# Gin 可信反向代理(逗号分隔的 IP/CIDR) +# 未配置时不信任任何代理,ClientIP 只使用直连地址。 +# 反向代理部署必须填写代理自身的 IP/CIDR,不要填客户端网段。 +# TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8 + # Gemini 识别图片 最大图片数量 # GEMINI_VISION_MAX_IMAGE_NUM=16 @@ -75,6 +80,16 @@ # 这些设置不修改 relay CORS。 # SESSION_COOKIE_SECURE=false # SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com +# 每用户最多保留的活跃登录 Session +# USER_SESSION_ACTIVE_LIMIT=50 +# 单用户在签发窗口内允许创建的 Session 总数(包含已撤销) +# USER_SESSION_ISSUANCE_LIMIT=100 +# Session 签发计数窗口(秒);不得大于 revoked 保留期,超出时会自动钳制 +# USER_SESSION_ISSUANCE_WINDOW_SECONDS=86400 +# revoked Session 审计保留天数 +# USER_SESSION_REVOKED_RETENTION_DAYS=7 +# 最近一小时全局 Session 签发量超过此值时记录告警,不会拒绝登录 +# USER_SESSION_HOURLY_ALERT_THRESHOLD=5000 # 其他配置 # 生成默认token diff --git a/README.en.md b/README.en.md index 323baa3d1b38..63f286089ac9 100644 --- a/README.en.md +++ b/README.en.md @@ -309,7 +309,13 @@ docker run --name new-api -d --restart always \ | `SESSION_SECRET` | Authentication signing secret; must be identical on every node | - | | `SESSION_COOKIE_SECURE` | `false`/unset disables the refresh/logout OriginGuard for local HTTP dev proxies; `true` enables the Secure cookie and strict Origin checks | `false` | | `SESSION_COOKIE_TRUSTED_URL` | Required with Secure mode: comma-separated exact HTTPS Origins allowed to call refresh/logout; not a relay CORS allowlist | - | -| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - | +| `TRUSTED_PROXIES` | Comma-separated reverse-proxy IPs/CIDRs trusted for client IP headers; unset trusts no proxies | - | +| `USER_SESSION_ACTIVE_LIMIT` | Maximum active login Sessions per user | `50` | +| `USER_SESSION_ISSUANCE_LIMIT` | Maximum Sessions created per user within the issuance window, including revoked Sessions | `100` | +| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Per-user Session issuance window; clamped to the revoked retention period when configured higher | `86400` | +| `USER_SESSION_REVOKED_RETENTION_DAYS` | Days to retain revoked Session rows for audit and issuance accounting | `7` | +| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | Global Sessions created per hour that triggers an alert only; it never blocks login | `5000` | +| `CRYPTO_SECRET` | HMAC secret for cache keys; nodes sharing Redis must use the same effective value | Defaults to `SESSION_SECRET` | | `SQL_DSN` | Database connection string | - | | `REDIS_CONN_STRING` | Redis connection string | - | | `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` | @@ -390,8 +396,18 @@ docker run --name new-api -d --restart always \ ### ⚠️ Multi-machine Deployment Considerations > [!WARNING] -> - **Must set the same** `SESSION_SECRET` on every node - Otherwise Access Tokens, refresh sessions and temporary authentication flows cannot be verified consistently -> - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted +> - All nodes must use the same primary database and the same `SESSION_SECRET`; otherwise Access Tokens, refresh sessions, and temporary authentication flows cannot be verified consistently. +> - Nodes connected to the same Redis must also use the same `CRYPTO_SECRET`, or their cache-key digests will differ and shared entries cannot be reused consistently. + +The database is authoritative for login Sessions and for the per-user active/issuance limits. Redis Session entries are short-lived caches whose TTL follows `SYNC_FREQUENCY` (60 seconds by default) and never exceeds the Session's remaining lifetime. + +| Redis topology | Session propagation | Rate limiting | +| --- | --- | --- | +| Shared Redis | Revocations and version publications normally propagate immediately | Redis limits are shared across nodes | +| Independent Redis per node | Nodes converge from the database within the effective `SYNC_FREQUENCY`; a newly rotated token may receive a temporary 401 on a node with stale cache | Each node has its own allowance, so aggregate capacity can reach roughly the configured limit multiplied by the node count | +| No Redis | Every Session validation reads the database | In-memory limits are independent per node | + +A shorter `SYNC_FREQUENCY` reduces the independent-Redis staleness window but causes one additional primary-key Session lookup per active SID, per node, per TTL. These guarantees make Session authentication bounded-stale across the supported topologies; rate limits and other Redis-backed control-plane caches remain topology-dependent. See [User authentication and login sessions](./docs/authentication.md) for the token, Origin-check and PAT contracts. diff --git a/README.fr.md b/README.fr.md index 6fcff93ecb50..9522601b590a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -316,7 +316,13 @@ docker run --name new-api -d --restart always \ | `SESSION_SECRET` | Secret de signature d’authentification, identique sur tous les nœuds | - | | `SESSION_COOKIE_SECURE` | `false`/non défini désactive l’OriginGuard de refresh/logout pour les proxys HTTP locaux ; `true` active le cookie Secure et le contrôle strict de l’Origin | `false` | | `SESSION_COOKIE_TRUSTED_URL` | Obligatoire en mode Secure : Origins HTTPS exactes autorisées pour refresh/logout, séparées par des virgules ; ce n’est pas une liste CORS relay | - | -| `CRYPTO_SECRET` | Secret de chiffrement (requis pour Redis) | - | +| `TRUSTED_PROXIES` | IP/CIDR des proxys inverses autorisés à fournir l’IP client, séparés par des virgules ; aucun proxy n’est approuvé par défaut | - | +| `USER_SESSION_ACTIVE_LIMIT` | Nombre maximal de Sessions de connexion actives par utilisateur | `50` | +| `USER_SESSION_ISSUANCE_LIMIT` | Nombre maximal de Sessions créées par utilisateur dans la fenêtre, y compris les Sessions révoquées | `100` | +| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Fenêtre de comptage des Sessions ; limitée à la durée de conservation des Sessions révoquées si elle est supérieure | `86400` | +| `USER_SESSION_REVOKED_RETENTION_DAYS` | Conservation en jours des Sessions révoquées pour l’audit et le comptage | `7` | +| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | Seuil global horaire déclenchant uniquement une alerte, sans bloquer les connexions | `5000` | +| `CRYPTO_SECRET` | Secret HMAC des clés de cache ; les nœuds partageant Redis doivent utiliser la même valeur effective | Par défaut, `SESSION_SECRET` | | `SQL_DSN` | Chaine de connexion à la base de données | - | | `REDIS_CONN_STRING` | Chaine de connexion Redis | - | | `STREAMING_TIMEOUT` | Délai d'expiration du streaming (secondes) | `300` | @@ -397,8 +403,18 @@ docker run --name new-api -d --restart always \ ### ⚠️ Considérations sur le déploiement multi-machines > [!WARNING] -> - **La même valeur** `SESSION_SECRET` doit être définie sur chaque nœud, sinon les Access Tokens, sessions Refresh et flux temporaires ne peuvent pas être vérifiés de façon cohérente -> - **Redis partagé doit définir** `CRYPTO_SECRET` - Sinon les données ne pourront pas être déchiffrées +> - Tous les nœuds doivent utiliser la même base de données principale et la même valeur `SESSION_SECRET` ; sinon les Access Tokens, sessions Refresh et flux d’authentification temporaires ne peuvent pas être vérifiés de façon cohérente. +> - Les nœuds connectés au même Redis doivent aussi utiliser le même `CRYPTO_SECRET`, faute de quoi les empreintes de clé de cache diffèrent et les entrées partagées ne peuvent pas être réutilisées de façon cohérente. + +La base de données fait autorité pour les Sessions de connexion et pour les limites actives/d’émission par utilisateur. Les entrées Session de Redis sont des caches de courte durée dont le TTL suit `SYNC_FREQUENCY` (60 secondes par défaut), sans jamais dépasser la durée de vie restante de la Session. + +| Topologie Redis | Propagation des Sessions | Limitation de débit | +| --- | --- | --- | +| Redis partagé | Les révocations et publications de version se propagent normalement immédiatement | Les quotas Redis sont partagés entre les nœuds | +| Redis indépendant par nœud | Les nœuds se resynchronisent depuis la base dans le délai effectif de `SYNC_FREQUENCY` ; un nouveau Token issu d’une rotation peut recevoir temporairement une réponse 401 sur un nœud dont le cache est obsolète | Chaque nœud possède son propre quota ; la capacité agrégée peut donc atteindre environ la limite configurée multipliée par le nombre de nœuds | +| Sans Redis | Chaque validation de Session consulte directement la base de données | Les limites en mémoire sont indépendantes sur chaque nœud | + +Réduire `SYNC_FREQUENCY` raccourcit la fenêtre d’obsolescence avec des Redis indépendants, mais ajoute une lecture de Session par clé primaire, par SID actif, par nœud et par TTL. Ces garanties donnent une obsolescence bornée à l’authentification Session ; les limites et les autres caches du plan de contrôle adossés à Redis restent dépendants de la topologie. Consultez [Authentification utilisateur et sessions de connexion](./docs/authentication.md) pour les contrats de token, de vérification Origin et de PAT. diff --git a/README.ja.md b/README.ja.md index bbd8a31c96a4..37b4862a69bb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -318,7 +318,13 @@ docker run --name new-api -d --restart always \ | `SESSION_SECRET` | 認証署名シークレット。すべてのノードで同じ値が必要 | - | | `SESSION_COOKIE_SECURE` | `false`/未設定ではローカル HTTP 開発プロキシ向けに refresh/logout の OriginGuard を無効化し、`true` では Secure Cookie と厳格な Origin 検証を有効化 | `false` | | `SESSION_COOKIE_TRUSTED_URL` | Secure モードでは必須。refresh/logout を許可する完全一致の HTTPS Origin をカンマ区切りで指定。relay CORS 設定ではありません | - | -| `CRYPTO_SECRET` | 暗号化シークレット(Redisに必須) | - | +| `TRUSTED_PROXIES` | クライアント IP ヘッダーを信頼するリバースプロキシの IP/CIDR。カンマ区切りで指定し、未設定時はプロキシを信頼しません | - | +| `USER_SESSION_ACTIVE_LIMIT` | 1 ユーザーあたりの有効なログイン Session 上限 | `50` | +| `USER_SESSION_ISSUANCE_LIMIT` | カウント期間内に作成できる Session 数の上限(取り消し済みを含む) | `100` | +| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 発行のカウント期間(秒)。取り消し済み Session の保持期間を超える場合は自動的に制限 | `86400` | +| `USER_SESSION_REVOKED_RETENTION_DAYS` | 監査と発行数計算のため取り消し済み Session を保持する日数 | `7` | +| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | 1 時間あたりのグローバル Session 発行数の警告閾値。ログインは拒否しません | `5000` | +| `CRYPTO_SECRET` | キャッシュキー用 HMAC シークレット。Redis を共有するノードでは同じ実効値が必要 | デフォルトは `SESSION_SECRET` | | `SQL_DSN** | データベース接続文字列 | - | | `REDIS_CONN_STRING` | Redis接続文字列 | - | | `STREAMING_TIMEOUT` | ストリーミング応答のタイムアウト時間(秒) | `300` | @@ -397,8 +403,18 @@ docker run --name new-api -d --restart always \ ### ⚠️ マルチマシンデプロイの注意事項 > [!WARNING] -> - すべてのノードに**同じ** `SESSION_SECRET` を設定してください。異なる場合、Access Token、Refresh セッション、一時認証フローを一貫して検証できません -> - **共有Redisは必ず設定する必要があります** `CRYPTO_SECRET` - そうしないとデータを復号化できません +> - すべてのノードで同じプライマリデータベースと同じ `SESSION_SECRET` を使用してください。異なる場合、Access Token、Refresh セッション、一時認証フローを一貫して検証できません。 +> - 同じ Redis に接続するノードでは同じ `CRYPTO_SECRET` も設定してください。異なる場合、キャッシュキーのダイジェストが一致せず、共有エントリを正しく再利用できません。 + +ログイン Session とユーザー単位の有効数/発行数制限では、データベースが信頼できる唯一の情報源です。Redis の Session エントリは短期キャッシュであり、TTL は `SYNC_FREQUENCY`(デフォルト 60 秒)に従い、Session の残り有効期間を超えません。 + +| Redis トポロジー | Session 状態の伝播 | レート制限 | +| --- | --- | --- | +| すべてのノードで Redis を共有 | 取り消しとバージョン更新は通常即時に伝播 | Redis の制限枠はノード間で共有 | +| ノードごとに独立した Redis | 有効な `SYNC_FREQUENCY` 以内にデータベースへフォールバックして収束。バージョンローテーション直後の新しい Token は、古いキャッシュを持つノードで一時的に 401 になる場合があります | ノードごとに独立して計数するため、クラスター全体では設定値の約ノード数倍まで許可される可能性があります | +| Redis なし | Session の検証ごとにデータベースを直接参照 | メモリ内の制限枠はノードごとに独立 | + +`SYNC_FREQUENCY` を短くすると独立 Redis のキャッシュ陳腐化時間は短くなりますが、有効な SID ごと、ノードごと、TTL ごとにデータベースへの主キー照会が 1 回増えます。この保証は Session 認証の陳腐化時間を限定するものです。レート制限や Redis を使うその他のコントロールプレーンキャッシュは、引き続きトポロジーに依存します。 Token、Origin 検証、PAT の契約については[ユーザー認証とログインセッション](./docs/authentication.md)を参照してください。 diff --git a/README.md b/README.md index 2e1848591672..9a35cc91212d 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,13 @@ docker run --name new-api -d --restart always \ | `SESSION_SECRET` | Authentication signing secret; must be identical on every node | - | | `SESSION_COOKIE_SECURE` | `false`/unset disables the refresh/logout OriginGuard for local HTTP dev proxies; `true` enables the Secure cookie and strict Origin checks | `false` | | `SESSION_COOKIE_TRUSTED_URL` | Required with Secure mode: comma-separated exact HTTPS Origins allowed to call refresh/logout; not a relay CORS allowlist | - | -| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - | +| `TRUSTED_PROXIES` | Comma-separated reverse-proxy IPs/CIDRs trusted for client IP headers; unset trusts no proxies | - | +| `USER_SESSION_ACTIVE_LIMIT` | Maximum active login Sessions per user | `50` | +| `USER_SESSION_ISSUANCE_LIMIT` | Maximum Sessions created per user within the issuance window, including revoked Sessions | `100` | +| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Per-user Session issuance window; clamped to the revoked retention period when configured higher | `86400` | +| `USER_SESSION_REVOKED_RETENTION_DAYS` | Days to retain revoked Session rows for audit and issuance accounting | `7` | +| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | Global Sessions created per hour that triggers an alert only; it never blocks login | `5000` | +| `CRYPTO_SECRET` | HMAC secret for cache keys; nodes sharing Redis must use the same effective value | Defaults to `SESSION_SECRET` | | `SQL_DSN` | Database connection string | - | | `REDIS_CONN_STRING` | Redis connection string | - | | `RELAY_IDLE_CONN_TIMEOUT` | Idle keep-alive timeout for relay HTTP clients, seconds. Defaults to Go standard library behavior; set `0` to disable | `90` | @@ -398,8 +404,18 @@ docker run --name new-api -d --restart always \ ### ⚠️ Multi-machine Deployment Considerations > [!WARNING] -> - **Must set the same** `SESSION_SECRET` on every node - Otherwise Access Tokens, refresh sessions and temporary authentication flows cannot be verified consistently -> - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted +> - All nodes must use the same primary database and the same `SESSION_SECRET`; otherwise Access Tokens, refresh sessions, and temporary authentication flows cannot be verified consistently. +> - Nodes connected to the same Redis must also use the same `CRYPTO_SECRET`, or their cache-key digests will differ and shared entries cannot be reused consistently. + +The database is authoritative for login Sessions and for the per-user active/issuance limits. Redis Session entries are short-lived caches whose TTL follows `SYNC_FREQUENCY` (60 seconds by default) and never exceeds the Session's remaining lifetime. + +| Redis topology | Session propagation | Rate limiting | +| --- | --- | --- | +| Shared Redis | Revocations and version publications normally propagate immediately | Redis limits are shared across nodes | +| Independent Redis per node | Nodes converge from the database within the effective `SYNC_FREQUENCY`; a newly rotated token may receive a temporary 401 on a node with stale cache | Each node has its own allowance, so aggregate capacity can reach roughly the configured limit multiplied by the node count | +| No Redis | Every Session validation reads the database | In-memory limits are independent per node | + +A shorter `SYNC_FREQUENCY` reduces the independent-Redis staleness window but causes one additional primary-key Session lookup per active SID, per node, per TTL. These guarantees make Session authentication bounded-stale across the supported topologies; rate limits and other Redis-backed control-plane caches remain topology-dependent. See [User authentication and login sessions](./docs/authentication.md) for the token, Origin-check and PAT contracts. diff --git a/README.zh_CN.md b/README.zh_CN.md index fa19497daed6..6323c4e4255a 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -316,7 +316,13 @@ docker run --name new-api -d --restart always \ | `SESSION_SECRET` | 鉴权签名密钥;所有节点必须保持一致 | - | | `SESSION_COOKIE_SECURE` | `false`/未配置时关闭 refresh/logout OriginGuard 以兼容本地 HTTP 开发代理;`true` 时启用 Secure Cookie 和严格 Origin 校验 | `false` | | `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:允许调用 refresh/logout 的精确 HTTPS Origin,多个用英文逗号分隔;不是 relay CORS 白名单 | - | -| `CRYPTO_SECRET` | 加密密钥(Redis 必须) | - | +| `TRUSTED_PROXIES` | 允许提供客户端 IP 请求头的可信反向代理 IP/CIDR,多个用逗号分隔;未配置时不信任任何代理 | - | +| `USER_SESSION_ACTIVE_LIMIT` | 单用户最大活跃登录 Session 数 | `50` | +| `USER_SESSION_ISSUANCE_LIMIT` | 单用户在签发窗口内可创建的 Session 总数,包含已撤销 Session | `100` | +| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 签发计数窗口(秒);高于 revoked 保留期时自动钳制 | `86400` | +| `USER_SESSION_REVOKED_RETENTION_DAYS` | revoked Session 用于审计和签发计数的保留天数 | `7` | +| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | 全局每小时 Session 签发告警阈值;只告警,不拒绝登录 | `5000` | +| `CRYPTO_SECRET` | 缓存键 HMAC 密钥;共享 Redis 的节点必须使用相同有效值 | 默认跟随 `SESSION_SECRET` | | `SQL_DSN` | 数据库连接字符串 | - | | `REDIS_CONN_STRING` | Redis 连接字符串 | - | | `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` | @@ -397,8 +403,18 @@ docker run --name new-api -d --restart always \ ### ⚠️ 多机部署注意事项 > [!WARNING] -> - 所有节点**必须设置相同的** `SESSION_SECRET` - 否则 Access Token、Refresh 会话和临时鉴权流程无法一致校验 -> - **公用 Redis 必须设置** `CRYPTO_SECRET` - 否则数据无法解密 +> - 所有节点必须使用同一个主数据库,并设置相同的 `SESSION_SECRET`;否则 Access Token、Refresh 会话和临时鉴权流程无法一致校验。 +> - 连接同一个 Redis 的节点还必须设置相同的 `CRYPTO_SECRET`,否则节点生成的缓存键摘要不一致,无法正确共享缓存。 + +登录 Session 和单用户活跃数/签发数限制均以数据库为权威。Redis 中的 Session 仅为短期缓存,TTL 跟随 `SYNC_FREQUENCY`(默认 60 秒),且不会超过 Session 的剩余寿命。 + +| Redis 拓扑 | Session 状态传播 | 限流语义 | +| --- | --- | --- | +| 所有节点共享 Redis | 撤销和版本发布通常即时传播 | Redis 限流额度在节点间共享 | +| 每个节点使用独立 Redis | 最迟在有效 `SYNC_FREQUENCY` 内回源数据库收敛;版本轮换后,新 Token 在持有旧缓存的节点上可能短暂返回 401 | 每个节点独立计数,集群总额度最坏约为单节点阈值乘以节点数 | +| 不使用 Redis | 每次 Session 校验直接读取数据库 | 各节点使用独立的内存限流额度 | + +缩短 `SYNC_FREQUENCY` 可减小独立 Redis 的陈旧窗口,但每个活跃 SID 在每个节点上会按该 TTL 增加一次数据库主键点查。上述保证只让 Session 鉴权在不同拓扑下保持有界陈旧;限流和其他 Redis 控制面缓存仍受拓扑影响。 Token、Origin 校验和 PAT 契约见[用户鉴权与登录会话](./docs/authentication.md)。 diff --git a/README.zh_TW.md b/README.zh_TW.md index 95d04e3468fb..abf1d2fc0bbd 100644 --- a/README.zh_TW.md +++ b/README.zh_TW.md @@ -316,7 +316,13 @@ docker run --name new-api -d --restart always \ | `SESSION_SECRET` | 鑑權簽章密鑰;所有節點必須保持一致 | - | | `SESSION_COOKIE_SECURE` | `false`/未設定時關閉 refresh/logout OriginGuard 以相容本機 HTTP 開發代理;`true` 時啟用 Secure Cookie 和嚴格 Origin 驗證 | `false` | | `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:允許呼叫 refresh/logout 的精確 HTTPS Origin,多個值以英文逗號分隔;不是 relay CORS 白名單 | - | -| `CRYPTO_SECRET` | 加密密鑰(Redis 必須) | - | +| `TRUSTED_PROXIES` | 允許提供用戶端 IP 請求標頭的可信反向代理 IP/CIDR,多個值以逗號分隔;未設定時不信任任何代理 | - | +| `USER_SESSION_ACTIVE_LIMIT` | 單一用戶最大活躍登入 Session 數 | `50` | +| `USER_SESSION_ISSUANCE_LIMIT` | 單一用戶在簽發視窗內可建立的 Session 總數,包含已撤銷 Session | `100` | +| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 簽發計數視窗(秒);高於 revoked 保留期時自動限制 | `86400` | +| `USER_SESSION_REVOKED_RETENTION_DAYS` | revoked Session 用於稽核與簽發計數的保留天數 | `7` | +| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | 全域每小時 Session 簽發告警門檻;只告警,不拒絕登入 | `5000` | +| `CRYPTO_SECRET` | 快取鍵 HMAC 密鑰;共用 Redis 的節點必須使用相同有效值 | 預設跟隨 `SESSION_SECRET` | | `SQL_DSN` | 資料庫連接字符串 | - | | `REDIS_CONN_STRING` | Redis 連接字符串 | - | | `STREAMING_TIMEOUT` | 流式超時時間(秒) | `300` | @@ -397,8 +403,18 @@ docker run --name new-api -d --restart always \ ### ⚠️ 多機部署注意事項 > [!WARNING] -> - 所有節點**必須設定相同的** `SESSION_SECRET` - 否則 Access Token、Refresh 工作階段和臨時鑑權流程無法一致驗證 -> - **公用 Redis 必須設置** `CRYPTO_SECRET` - 否則數據無法解密 +> - 所有節點必須使用同一個主資料庫,並設定相同的 `SESSION_SECRET`;否則 Access Token、Refresh 工作階段和臨時鑑權流程無法一致驗證。 +> - 連線至同一個 Redis 的節點還必須設定相同的 `CRYPTO_SECRET`,否則節點產生的快取鍵摘要不一致,無法正確共用快取。 + +登入 Session 和單一使用者的活躍數/簽發數限制均以資料庫為權威。Redis 中的 Session 僅為短期快取,TTL 跟隨 `SYNC_FREQUENCY`(預設 60 秒),且不會超過 Session 的剩餘有效期。 + +| Redis 拓撲 | Session 狀態傳播 | 限流語義 | +| --- | --- | --- | +| 所有節點共用 Redis | 撤銷和版本發布通常即時傳播 | Redis 限流額度在節點間共用 | +| 每個節點使用獨立 Redis | 最遲在有效 `SYNC_FREQUENCY` 內回源資料庫並收斂;版本輪換後,新 Token 在持有舊快取的節點上可能短暫傳回 401 | 每個節點獨立計數,叢集總額度最壞約為單一節點門檻乘以節點數 | +| 不使用 Redis | 每次 Session 驗證都直接讀取資料庫 | 各節點使用獨立的記憶體限流額度 | + +縮短 `SYNC_FREQUENCY` 可減少獨立 Redis 的陳舊視窗,但每個活躍 SID 在每個節點上會依該 TTL 增加一次資料庫主鍵查詢。上述保證只讓 Session 鑑權在不同拓撲下維持有界陳舊;限流和其他 Redis 控制面快取仍受拓撲影響。 Token、Origin 驗證和 PAT 契約請參閱[使用者鑑權與登入工作階段](./docs/authentication.md)。 diff --git a/common/constants.go b/common/constants.go index 87d212f99732..45699d0e1f01 100644 --- a/common/constants.go +++ b/common/constants.go @@ -77,6 +77,22 @@ var CryptoSecret = uuid.New().String() var SessionCookieSecure = false var SessionCookieTrustedURLs []string +const ( + DefaultUserSessionActiveLimit = 50 + DefaultUserSessionIssuanceLimit = 100 + DefaultUserSessionIssuanceWindowSeconds = 24 * 60 * 60 + DefaultUserSessionRevokedRetentionDays = 7 + DefaultUserSessionHourlyAlertThreshold = 5000 +) + +var ( + UserSessionActiveLimit = DefaultUserSessionActiveLimit + UserSessionIssuanceLimit = DefaultUserSessionIssuanceLimit + UserSessionIssuanceWindowSeconds = int64(DefaultUserSessionIssuanceWindowSeconds) + UserSessionRevokedRetentionDays = DefaultUserSessionRevokedRetentionDays + UserSessionHourlyAlertThreshold = DefaultUserSessionHourlyAlertThreshold +) + var OptionMap map[string]string var OptionMapRWMutex sync.RWMutex diff --git a/common/init.go b/common/init.go index 88b2dc3e62e1..4d4c62b27cac 100644 --- a/common/init.go +++ b/common/init.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "log" + "math" "net/http" "os" "path/filepath" @@ -64,6 +65,7 @@ func InitEnv() { if err := InitSessionCookieSettings(); err != nil { log.Fatal(err) } + initUserSessionSettings() if os.Getenv("SQLITE_PATH") != "" { SQLitePath = os.Getenv("SQLITE_PATH") } @@ -134,6 +136,43 @@ func InitEnv() { initConstantEnv() } +func initUserSessionSettings() { + UserSessionActiveLimit = positiveUserSessionEnv("USER_SESSION_ACTIVE_LIMIT", DefaultUserSessionActiveLimit) + UserSessionIssuanceLimit = positiveUserSessionEnv("USER_SESSION_ISSUANCE_LIMIT", DefaultUserSessionIssuanceLimit) + UserSessionIssuanceWindowSeconds = int64(positiveUserSessionEnv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", DefaultUserSessionIssuanceWindowSeconds)) + UserSessionRevokedRetentionDays = positiveUserSessionEnv("USER_SESSION_REVOKED_RETENTION_DAYS", DefaultUserSessionRevokedRetentionDays) + UserSessionHourlyAlertThreshold = positiveUserSessionEnv("USER_SESSION_HOURLY_ALERT_THRESHOLD", DefaultUserSessionHourlyAlertThreshold) + + const secondsPerDay = 24 * 60 * 60 + if int64(UserSessionRevokedRetentionDays) > math.MaxInt64/secondsPerDay { + SysError(fmt.Sprintf( + "USER_SESSION_REVOKED_RETENTION_DAYS is too large, using default value: %d", + DefaultUserSessionRevokedRetentionDays, + )) + UserSessionRevokedRetentionDays = DefaultUserSessionRevokedRetentionDays + } + retentionSeconds := int64(UserSessionRevokedRetentionDays) * secondsPerDay + if UserSessionIssuanceWindowSeconds > retentionSeconds { + configuredWindow := UserSessionIssuanceWindowSeconds + UserSessionIssuanceWindowSeconds = retentionSeconds + SysError(fmt.Sprintf( + "USER_SESSION_ISSUANCE_WINDOW_SECONDS exceeds revoked retention; configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d", + configuredWindow, + retentionSeconds, + UserSessionIssuanceWindowSeconds, + )) + } +} + +func positiveUserSessionEnv(name string, fallback int) int { + value := GetEnvOrDefault(name, fallback) + if value <= 0 { + SysError(fmt.Sprintf("%s must be positive, using default value: %d", name, fallback)) + return fallback + } + return value +} + func initConstantEnv() { constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300) constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true) diff --git a/common/user_session_test.go b/common/user_session_test.go new file mode 100644 index 000000000000..a6c2a5c36bff --- /dev/null +++ b/common/user_session_test.go @@ -0,0 +1,60 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInitUserSessionSettingsUsesPositiveFallbacksAndClampsWindow(t *testing.T) { + previousActiveLimit := UserSessionActiveLimit + previousIssuanceLimit := UserSessionIssuanceLimit + previousIssuanceWindow := UserSessionIssuanceWindowSeconds + previousRevokedRetention := UserSessionRevokedRetentionDays + previousAlertThreshold := UserSessionHourlyAlertThreshold + t.Cleanup(func() { + UserSessionActiveLimit = previousActiveLimit + UserSessionIssuanceLimit = previousIssuanceLimit + UserSessionIssuanceWindowSeconds = previousIssuanceWindow + UserSessionRevokedRetentionDays = previousRevokedRetention + UserSessionHourlyAlertThreshold = previousAlertThreshold + }) + + t.Setenv("USER_SESSION_ACTIVE_LIMIT", "0") + t.Setenv("USER_SESSION_ISSUANCE_LIMIT", "-2") + t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "invalid") + t.Setenv("USER_SESSION_REVOKED_RETENTION_DAYS", "0") + t.Setenv("USER_SESSION_HOURLY_ALERT_THRESHOLD", "-1") + initUserSessionSettings() + + assert.Equal(t, DefaultUserSessionActiveLimit, UserSessionActiveLimit) + assert.Equal(t, DefaultUserSessionIssuanceLimit, UserSessionIssuanceLimit) + assert.Equal(t, int64(DefaultUserSessionIssuanceWindowSeconds), UserSessionIssuanceWindowSeconds) + assert.Equal(t, DefaultUserSessionRevokedRetentionDays, UserSessionRevokedRetentionDays) + assert.Equal(t, DefaultUserSessionHourlyAlertThreshold, UserSessionHourlyAlertThreshold) + + t.Setenv("USER_SESSION_ACTIVE_LIMIT", "12") + t.Setenv("USER_SESSION_ISSUANCE_LIMIT", "34") + t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "172800") + t.Setenv("USER_SESSION_REVOKED_RETENTION_DAYS", "1") + t.Setenv("USER_SESSION_HOURLY_ALERT_THRESHOLD", "56") + initUserSessionSettings() + + assert.Equal(t, 12, UserSessionActiveLimit) + assert.Equal(t, 34, UserSessionIssuanceLimit) + assert.Equal(t, int64(24*60*60), UserSessionIssuanceWindowSeconds) + assert.Equal(t, 1, UserSessionRevokedRetentionDays) + assert.Equal(t, 56, UserSessionHourlyAlertThreshold) + + t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "43200") + initUserSessionSettings() + assert.Equal(t, int64(12*60*60), UserSessionIssuanceWindowSeconds, "a window below retention remains unchanged") + + t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "86400") + initUserSessionSettings() + assert.Equal(t, int64(24*60*60), UserSessionIssuanceWindowSeconds, "a window equal to retention remains unchanged") + + t.Setenv("USER_SESSION_REVOKED_RETENTION_DAYS", "9223372036854775807") + initUserSessionSettings() + assert.Equal(t, DefaultUserSessionRevokedRetentionDays, UserSessionRevokedRetentionDays) +} diff --git a/controller/auth_session_test.go b/controller/auth_session_test.go index 706f076c0c80..670d7ce9ff8f 100644 --- a/controller/auth_session_test.go +++ b/controller/auth_session_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" @@ -65,3 +66,90 @@ func TestAuthLogoutRejectsRefreshCookieSessionMismatch(t *testing.T) { assert.Equal(t, model.UserSessionStatusActive, stored.Status) } } + +func TestWriteAuthSessionErrorMapsSessionGrowthLimits(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + err error + expectedStatus int + expectedCode string + }{ + { + name: "active session limit", + err: model.ErrUserSessionLimit, + expectedStatus: http.StatusConflict, + expectedCode: "AUTH_SESSION_LIMIT", + }, + { + name: "issuance limit", + err: model.ErrUserSessionIssuanceLimit, + expectedStatus: http.StatusTooManyRequests, + expectedCode: "AUTH_SESSION_ISSUANCE_LIMIT", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + writeAuthSessionError(c, test.err) + + assert.Equal(t, test.expectedStatus, recorder.Code) + var response struct { + Success bool `json:"success"` + Code string `json:"code"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + assert.False(t, response.Success) + assert.Equal(t, test.expectedCode, response.Code) + }) + } +} + +func TestSessionLimitDoesNotRecordRejectedLoginAsSuccessful(t *testing.T) { + previousDB := model.DB + previousRedis := common.RedisEnabled + previousActiveLimit := common.UserSessionActiveLimit + previousIssuanceLimit := common.UserSessionIssuanceLimit + previousIssuanceWindow := common.UserSessionIssuanceWindowSeconds + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{})) + model.DB = db + common.RedisEnabled = false + common.UserSessionActiveLimit = 1 + common.UserSessionIssuanceLimit = 100 + common.UserSessionIssuanceWindowSeconds = int64(common.DefaultUserSessionIssuanceWindowSeconds) + t.Cleanup(func() { + model.DB = previousDB + common.RedisEnabled = previousRedis + common.UserSessionActiveLimit = previousActiveLimit + common.UserSessionIssuanceLimit = previousIssuanceLimit + common.UserSessionIssuanceWindowSeconds = previousIssuanceWindow + }) + + const previousLastLoginAt = int64(123) + user := &model.User{ + Username: "rejected-login-audit-user", Password: "unused", Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, LastLoginAt: previousLastLoginAt, + } + require.NoError(t, db.Create(user).Error) + now := time.Now().Unix() + require.NoError(t, db.Create(&model.UserSession{ + SID: "existing-active-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion, + Status: model.UserSessionStatusActive, RefreshHash: "hash", LoginMethod: "password", + CreatedAt: now, LastActiveAt: now, ExpiresAt: now + 3600, + }).Error) + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/user/login", nil) + setupLogin(user, c) + + assert.Equal(t, http.StatusConflict, recorder.Code) + var stored model.User + require.NoError(t, db.First(&stored, user.Id).Error) + assert.Equal(t, previousLastLoginAt, stored.LastLoginAt) +} diff --git a/controller/user.go b/controller/user.go index 7c53b770a2ba..202b43b677a7 100644 --- a/controller/user.go +++ b/controller/user.go @@ -165,7 +165,6 @@ func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin common.ApiError(c, err) return } - model.UpdateUserLastLoginAt(user.Id) var bundle *service.AuthBundle if expectedAuthVersion > 0 { bundle, err = service.CreateLoginSessionAtAuthVersion( @@ -184,9 +183,10 @@ func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin ) } if err != nil { - common.ApiError(c, err) + writeAuthSessionError(c, err) return } + model.UpdateUserLastLoginAt(user.Id) service.WriteRefreshCookie(c, bundle.RefreshToken) setAuthNoStore(c) recordLoginAudit(user, c) diff --git a/docker-compose.yml b/docker-compose.yml index b2f35620a26e..279861e8e6a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,12 @@ services: # - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!) # - SESSION_COOKIE_SECURE=true # true:启用 Secure Refresh Cookie 和严格 refresh/logout OriginGuard;false/未配置:关闭 OriginGuard,仅用于本地 HTTP (true: Secure cookie + strict refresh/logout OriginGuard; false/unset: guard disabled for local HTTP only) # - SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com # Secure=true 时必填的精确 HTTPS Origin;不是 relay CORS 白名单,不支持通配符/路径 (Required exact HTTPS origins when Secure=true; not a relay CORS allowlist, no wildcard/path) +# - TRUSTED_PROXIES=172.16.0.0/12 # 可信反向代理 IP/CIDR;未配置时忽略 X-Forwarded-For (Trusted reverse-proxy IPs/CIDRs; X-Forwarded-For is ignored when unset) +# - USER_SESSION_ACTIVE_LIMIT=50 +# - USER_SESSION_ISSUANCE_LIMIT=100 +# - USER_SESSION_ISSUANCE_WINDOW_SECONDS=86400 # 不得大于 revoked 保留期 (must not exceed revoked retention) +# - USER_SESSION_REVOKED_RETENTION_DAYS=7 +# - USER_SESSION_HOURLY_ALERT_THRESHOLD=5000 # 仅告警,不做全局拒绝 (alert only; never globally rejects login) # - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed # - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID) # - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID) diff --git a/docs/authentication.md b/docs/authentication.md index 22db295a2b22..1f2ed30e46ef 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -6,12 +6,26 @@ - Access Token 是有效期 15 分钟的 JWT,只保存在浏览器内存中,通过 `Authorization: Bearer ` 发送。 - Refresh Token 是随机不透明值,有效期最长 30 天。浏览器只通过 `HttpOnly`、`SameSite=Strict` Cookie 持有它;服务端仅保存 HMAC 摘要,并在每次刷新时轮换。 -- `user_sessions` 是登录会话控制面,记录设备、IP、登录方式、最后活跃时间、到期时间和撤销状态。单个会话被撤销后,其 Access Token 会立即失效。 +- `user_sessions` 是登录会话控制面,记录设备、IP、登录方式、最后活跃时间、到期时间和撤销状态。数据库中的 Session 状态是最终权威;撤销传播速度取决于下文所述的 Redis 拓扑。 - 用户的密码、状态、角色或安全因子发生安全相关变化时,`auth_version` 会递增并使旧登录会话失效。订阅带来的分组升降级只刷新授权缓存,不会退出任何登录设备。 -- Redis 缓存保存用户鉴权快照和登录会话快照。版本栅栏和撤销 tombstone 防止旧缓存重新授权;未启用 Redis 时回退到数据库校验。 +- Redis 缓存保存用户鉴权快照和登录会话快照。版本栅栏和撤销 tombstone 防止旧缓存重新授权;Session 快照使用跟随 `SYNC_FREQUENCY` 的短 TTL,缓存未命中或未启用 Redis 时回退到数据库校验。 `SESSION_SECRET` 用于派生 Access Token、Security Proof、Refresh Token 摘要和 AuthFlow 摘要的不同用途密钥。生产环境及多节点部署必须在所有节点配置相同的高强度随机值;更换该值会使现有登录、临时鉴权流程和 Security Proof 全部失效。 +## 多节点 Redis 拓扑 + +多节点部署必须共用同一主数据库。登录 Session、账户级活跃 Session 上限和签发窗口计数都以数据库为权威,因此这些限制在应用节点间全局生效。Redis 中的 Session Hash(包含 `revoking`/`revoked` tombstone)只是缓存,其 TTL 为 Session 剩余寿命与有效 `SYNC_FREQUENCY` 中的较小值;`SYNC_FREQUENCY` 默认及非法值回退均为 `60` 秒。读取缓存不会续期,过期后会按 SID 回源数据库。延迟完成的 active 缓存回写只能使用其数据库观察窗口尚未消耗的 TTL,不能在撤销 tombstone 到期后重新启动一个完整缓存周期。 + +| Redis 部署方式 | Session 状态传播 | 限流语义 | +| --- | --- | --- | +| 所有节点共享 Redis | 正常撤销和版本发布通过同一缓存即时传播 | Redis 限流额度在所有节点间共享 | +| 每个节点使用独立 Redis | 最迟在该节点 Session 缓存 TTL 到期后回源收敛,即不超过有效 `SYNC_FREQUENCY`;版本轮换期间,新 Token 在持有旧缓存的节点上可能短暂返回 401 | 每个节点独立计数,集群总额度最坏约为单节点阈值乘以节点数 | +| 不使用 Redis | 每次 Session 校验直接读取数据库 | 使用各节点的内存限流器,额度同样按节点独立 | + +`SYNC_FREQUENCY` 越大,独立 Redis 部署的陈旧窗口越长;值越小,每个活跃 SID 在每个节点上回源数据库的频率越高。默认配置下,持续活跃的 Session 每个节点最多约每 60 秒增加一次数据库主键点查。共享 Redis 时,撤销 tombstone 和版本发布仍保持即时传播。 + +所有节点必须使用相同的 `SESSION_SECRET`。当多个节点连接同一个 Redis 时,还必须使用相同的 `CRYPTO_SECRET`,否则节点生成的缓存键摘要不一致,无法正确共享缓存。上述保证只覆盖登录 Session 鉴权的有界陈旧语义;限流额度及其他 Redis 缓存仍会受到 Redis 拓扑影响,不能据此认为整个控制面与拓扑无关。 + ## 浏览器接口 登录成功后,密码登录、2FA、Passkey、OAuth、WeChat 和 Telegram 登录均返回统一数据: @@ -44,7 +58,7 @@ | --- | --- | --- | | `POST /api/user/auth/refresh` | Refresh Cookie;Secure 模式附加 Origin 校验 | 轮换 Refresh Token 并签发新的 Access Token | | `POST /api/user/auth/logout` | Refresh Cookie;Secure 模式附加 Origin 校验,可同时携带 Bearer | 撤销当前登录会话并清除 Cookie | -| `GET /api/user/sessions` | Bearer | 查看当前用户的全部有效登录会话 | +| `GET /api/user/sessions` | Bearer | 查看当前鉴权版本的有效登录会话,当前会话优先,最多 100 条 | | `DELETE /api/user/sessions/:sid` | Bearer | 撤销指定登录会话,包括当前会话 | | `POST /api/user/sessions/revoke-others` | Bearer | 保留当前会话并撤销其他会话 | @@ -56,6 +70,24 @@ 前端将冷启动状态与登录状态分开管理。网络或服务端临时故障允许后续导航重试 refresh;服务端确认 Refresh Cookie 无效时才进入已完成的匿名状态。内存 SID 与 Cookie SID 不一致时,客户端清除旧内存身份并在不携带旧 SID 的情况下重试一次。 +## Session 签发限额与保留策略 + +服务端在所有登录方式的统一 Session 签发出口执行两级账户限制: + +- `USER_SESSION_ACTIVE_LIMIT`(默认 `50`):单用户未过期且状态为 active 的 Session 上限。达到上限时新登录返回 `409 AUTH_SESSION_LIMIT`。 +- `USER_SESSION_ISSUANCE_LIMIT`(默认 `100`)和 `USER_SESSION_ISSUANCE_WINDOW_SECONDS`(默认 `86400`):统计窗口内该用户创建的所有 Session,包含已撤销和旧鉴权版本的记录。达到上限时返回 `429 AUTH_SESSION_ISSUANCE_LIMIT`。 +- 这两次计数与插入不加跨数据库锁;极端并发登录可能出现少量超额,但计数失败会拒绝签发,不会降级放行。 + +升级时已经超过活跃上限的账户不会被自动下线或挤掉旧会话;限制只作用于后续的新 Session 签发。 + +`USER_SESSION_REVOKED_RETENTION_DAYS`(默认 `7`)控制 revoked 行的审计保留期。签发计数依赖窗口内的行仍存在,因此签发窗口不得超过 revoked 保留期。如果配置超出,启动时会记录告警并将实际窗口钳制到保留期,避免提前删除 revoked 行导致限流计数被低估。 + +定时清理即使发现 `expires_at` 已过期,也不会删除 `created_at` 仍落在实际签发窗口内的行;尚未达到 revoked 保留期的撤销记录同样会继续保留。这样在扩大配置窗口时,过期清理不会静默削弱签发计数或审计保留。 + +活跃数量会计入状态仍为 active 但 `user_auth_version` 已过期的异常残留行,而设备列表只展示当前鉴权版本。因此遇到 `AUTH_SESSION_LIMIT` 时,应优先在仍已登录的设备上执行“撤销其他会话”,该操作会同时清理不可见的旧版本 active 行;没有可用设备时可使用密码重置撤销所有会话。密码重置不会清空签发窗口计数。 + +仅 master 节点每小时分批删除过期 Session 和超过保留期的 revoked Session。`USER_SESSION_HOURLY_ALERT_THRESHOLD`(默认 `5000`)只在最近一小时全局签发量异常时记录告警,不会形成可被滥用的全站登录拒绝开关。 + ## Refresh/Logout 的 Origin 校验 refresh/logout 的 Origin 防护与 Refresh Cookie 的 Secure 模式绑定: @@ -90,6 +122,18 @@ SESSION_COOKIE_TRUSTED_URL=https://panel.example.com,https://admin.example.com 该开关只控制面板 Refresh Cookie 和 refresh/logout 的 OriginGuard,不会修改 relay、旧 billing dashboard、`/api/usage/token` 或 `/api/log/token` 的 CORS 行为。 +## 可信代理与 IP 限流 + +Gin 默认会信任所有代理提供的客户端 IP 请求头,本项目不再使用该默认值: + +- 未配置 `TRUSTED_PROXIES` 时不信任任何代理,`ClientIP()` 只使用直连地址,客户端自行伪造 `X-Forwarded-For` 不会改变限流桶。 +- 反向代理部署应将代理自身的 IP 或 CIDR 以英文逗号分隔写入 `TRUSTED_PROXIES`,不要填客户端网段。非空但无效的配置会阻止服务启动。 +- 升级后如未在反代部署中配置该变量,限流和 Session 审计 IP 会记录代理地址。 + +Redis 限流使用原子 Lua 固定窗口,替代旧的近似滑动窗口 List 实现。这是有意的语义变化:窗口边界两侧可分别打满一次,极短时间内通过量最高约为配置值的两倍。例如 `20 次/20 分钟` 在边界可通过约 40 次。帐户级 Session 上限和签发窗口继续控制数据库增长;如未来需要严格抑制边界突发,需单独迁移为 ZSET 滑动窗口。 + +开放注册仍会受 Critical IP 限流保护,但分布式 IP 多账号攻击不能仅靠 IP 限流阻止。公网开放注册的部署应同时启用 Turnstile 和邮箱验证;更强的设备或多维风控需作为独立安全项目设计。 + ## PAT 调用契约 `User.AccessToken`(面板 PAT)继续支持 `Authorization: Bearer `,也兼容原有的单值 `Authorization: `。`New-Api-User` 不再参与鉴权,外部脚本不需要再发送 Bearer 与用户 ID 双请求头。这是有意的调用契约简化;旧 PAT 本身无需重新生成。 @@ -116,5 +160,8 @@ Proof 同时绑定用户、登录会话、用户鉴权版本、会话版本和 s - 旧 `session` Cookie 不再使用;升级后现有面板登录会失效,用户需要重新登录。 - 数据库迁移会新增 `user_sessions`、`auth_flows`、`external_identity_claims` 和 `users.auth_version`,并为已有用户初始化鉴权版本、回填 Telegram 账号唯一归属;若历史数据中同一 Telegram ID 已绑定多个用户,迁移会拒绝继续启动,需先消除歧义。 -- 仅 master 节点定时清理过期登录会话和已过保留期的 AuthFlow。 +- 数据库迁移会为 Session 签发计数和分批清理新增索引;已有 `user_sessions` 很大时应为首次启动预留维护窗口。 +- 仅 master 节点定时清理过期登录会话、超过配置保留期的 revoked 会话和已过保留期的 AuthFlow。 +- 反向代理部署在升级前必须配置 `TRUSTED_PROXIES`,否则所有请求会按代理的直连 IP 限流。 +- Redis 限流从近似滑动窗口改为原子固定窗口,存在明确的边界双倍突发语义。 - 自建客户端应按新的 AuthBundle、`flow_token` 和 Security Proof 契约升级;PAT 客户端可直接移除 `New-Api-User`。 diff --git a/docs/openapi/api.json b/docs/openapi/api.json index 3de3f8624622..a6cdaae6dac0 100644 --- a/docs/openapi/api.json +++ b/docs/openapi/api.json @@ -569,7 +569,7 @@ "post": { "summary": "用户登录", "deprecated": false, - "description": "🔓 无需鉴权", + "description": "🔓 无需鉴权。签发新登录 Session 时可能返回 409 AUTH_SESSION_LIMIT 或 429 AUTH_SESSION_ISSUANCE_LIMIT。", "tags": [ "用户登陆注册" ], @@ -595,6 +595,26 @@ "200": { "description": "成功", "headers": {} + }, + "409": { + "description": "该用户的活跃登录 Session 已达上限", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSessionIssuanceError" + } + } + } + }, + "429": { + "description": "该用户在统计窗口内创建的 Session 已达上限", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSessionIssuanceError" + } + } + } } }, "security": [ @@ -611,7 +631,7 @@ "post": { "summary": "两步验证登录", "deprecated": false, - "description": "🔓 无需鉴权(登录流程)", + "description": "🔓 无需鉴权(登录流程)。签发新登录 Session 时可能返回 409 AUTH_SESSION_LIMIT 或 429 AUTH_SESSION_ISSUANCE_LIMIT。", "tags": [ "用户登陆注册" ], @@ -638,6 +658,26 @@ "200": { "description": "成功", "headers": {} + }, + "409": { + "description": "该用户的活跃登录 Session 已达上限", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSessionIssuanceError" + } + } + } + }, + "429": { + "description": "该用户在统计窗口内创建的 Session 已达上限", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSessionIssuanceError" + } + } + } } }, "security": [ @@ -714,14 +754,14 @@ "get": { "summary": "查看登录会话", "deprecated": false, - "description": "🔐 需要面板 Access Token;PAT 不能管理浏览器登录会话", + "description": "🔐 需要面板 Access Token;PAT 不能管理浏览器登录会话。只返回当前 user_auth_version 的有效会话,当前会话优先,最多 100 条。", "tags": [ "用户登陆注册" ], "parameters": [], "responses": { "200": { - "description": "返回当前用户的有效登录会话", + "description": "返回当前鉴权版本的有效登录会话", "headers": {} } }, @@ -768,7 +808,7 @@ "post": { "summary": "撤销其他登录会话", "deprecated": false, - "description": "🔐 保留当前登录会话并撤销该用户的其他会话", + "description": "🔐 保留当前登录会话并撤销该用户的其他会话,包含列表不可见的旧 user_auth_version active 残留行", "tags": [ "用户登陆注册" ], @@ -840,7 +880,7 @@ "post": { "summary": "完成Passkey登录", "deprecated": false, - "description": "🔓 无需鉴权", + "description": "🔓 无需鉴权。签发新登录 Session 时可能返回 409 AUTH_SESSION_LIMIT 或 429 AUTH_SESSION_ISSUANCE_LIMIT。", "tags": [ "用户登陆注册" ], @@ -849,6 +889,26 @@ "200": { "description": "成功", "headers": {} + }, + "409": { + "description": "该用户的活跃登录 Session 已达上限", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSessionIssuanceError" + } + } + } + }, + "429": { + "description": "该用户在统计窗口内创建的 Session 已达上限", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSessionIssuanceError" + } + } + } } }, "security": [ @@ -865,7 +925,7 @@ "get": { "summary": "GitHub OAuth登录", "deprecated": false, - "description": "🔓 无需鉴权(OAuth回调)", + "description": "🔓 无需鉴权(OAuth 回调)。创建本地登录 Session 时同样受 AUTH_SESSION_LIMIT 和 AUTH_SESSION_ISSUANCE_LIMIT 约束。", "tags": [ "OAuth" ], @@ -884,6 +944,12 @@ "200": { "description": "成功", "headers": {} + }, + "409": { + "$ref": "#/components/responses/AuthSessionLimit" + }, + "429": { + "$ref": "#/components/responses/AuthSessionIssuanceLimit" } }, "security": [ @@ -900,7 +966,7 @@ "get": { "summary": "Discord OAuth登录", "deprecated": false, - "description": "🔓 无需鉴权(OAuth回调)", + "description": "🔓 无需鉴权(OAuth 回调)。创建本地登录 Session 时同样受 AUTH_SESSION_LIMIT 和 AUTH_SESSION_ISSUANCE_LIMIT 约束。", "tags": [ "OAuth" ], @@ -919,6 +985,12 @@ "200": { "description": "成功", "headers": {} + }, + "409": { + "$ref": "#/components/responses/AuthSessionLimit" + }, + "429": { + "$ref": "#/components/responses/AuthSessionIssuanceLimit" } }, "security": [ @@ -935,7 +1007,7 @@ "get": { "summary": "OIDC登录", "deprecated": false, - "description": "🔓 无需鉴权(OAuth回调)", + "description": "🔓 无需鉴权(OAuth 回调)。创建本地登录 Session 时同样受 AUTH_SESSION_LIMIT 和 AUTH_SESSION_ISSUANCE_LIMIT 约束。", "tags": [ "OAuth" ], @@ -944,6 +1016,12 @@ "200": { "description": "成功", "headers": {} + }, + "409": { + "$ref": "#/components/responses/AuthSessionLimit" + }, + "429": { + "$ref": "#/components/responses/AuthSessionIssuanceLimit" } }, "security": [ @@ -960,7 +1038,7 @@ "get": { "summary": "LinuxDO OAuth登录", "deprecated": false, - "description": "🔓 无需鉴权(OAuth回调)", + "description": "🔓 无需鉴权(OAuth 回调)。创建本地登录 Session 时同样受 AUTH_SESSION_LIMIT 和 AUTH_SESSION_ISSUANCE_LIMIT 约束。", "tags": [ "OAuth" ], @@ -969,6 +1047,12 @@ "200": { "description": "成功", "headers": {} + }, + "409": { + "$ref": "#/components/responses/AuthSessionLimit" + }, + "429": { + "$ref": "#/components/responses/AuthSessionIssuanceLimit" } }, "security": [ @@ -1040,7 +1124,7 @@ "get": { "summary": "微信OAuth登录", "deprecated": false, - "description": "🔓 无需鉴权(OAuth回调)", + "description": "🔓 无需鉴权(OAuth 回调)。创建本地登录 Session 时同样受 AUTH_SESSION_LIMIT 和 AUTH_SESSION_ISSUANCE_LIMIT 约束。", "tags": [ "OAuth" ], @@ -1049,6 +1133,12 @@ "200": { "description": "成功", "headers": {} + }, + "409": { + "$ref": "#/components/responses/AuthSessionLimit" + }, + "429": { + "$ref": "#/components/responses/AuthSessionIssuanceLimit" } }, "security": [ @@ -1150,7 +1240,7 @@ "get": { "summary": "Telegram登录", "deprecated": false, - "description": "🔓 无需鉴权(OAuth回调)", + "description": "🔓 无需鉴权(OAuth 回调)。创建本地登录 Session 时同样受 AUTH_SESSION_LIMIT 和 AUTH_SESSION_ISSUANCE_LIMIT 约束。", "tags": [ "OAuth" ], @@ -1159,6 +1249,12 @@ "200": { "description": "成功", "headers": {} + }, + "409": { + "$ref": "#/components/responses/AuthSessionLimit" + }, + "429": { + "$ref": "#/components/responses/AuthSessionIssuanceLimit" } }, "security": [ @@ -5162,6 +5258,32 @@ "data": {} } }, + "AuthSessionIssuanceError": { + "type": "object", + "required": [ + "success", + "code", + "message" + ], + "properties": { + "success": { + "type": "boolean", + "enum": [ + false + ] + }, + "code": { + "type": "string", + "enum": [ + "AUTH_SESSION_LIMIT", + "AUTH_SESSION_ISSUANCE_LIMIT" + ] + }, + "message": { + "type": "string" + } + } + }, "PageInfo": { "type": "object", "properties": { @@ -5326,7 +5448,28 @@ } } }, - "responses": {}, + "responses": { + "AuthSessionLimit": { + "description": "该用户的活跃登录 Session 已达上限", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSessionIssuanceError" + } + } + } + }, + "AuthSessionIssuanceLimit": { + "description": "该用户在统计窗口内创建的 Session 已达上限", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSessionIssuanceError" + } + } + } + } + }, "securitySchemes": { "SessionAuth1": { "type": "apiKey", diff --git a/main.go b/main.go index d2ed52384812..b5cfff19bb2c 100644 --- a/main.go +++ b/main.go @@ -171,6 +171,10 @@ func main() { // Initialize HTTP server server := gin.New() + if err := configureTrustedProxies(server); err != nil { + common.FatalLog("failed to configure trusted proxies: " + err.Error()) + return + } server.Use(gin.CustomRecovery(func(c *gin.Context, err any) { common.SysLog(fmt.Sprintf("panic detected: %v", err)) c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/middleware/email-verification-rate-limit.go b/middleware/email-verification-rate-limit.go index 470d7731cb0f..49081b55d466 100644 --- a/middleware/email-verification-rate-limit.go +++ b/middleware/email-verification-rate-limit.go @@ -1,10 +1,8 @@ package middleware import ( - "context" "fmt" "net/http" - "time" "github.com/QuantumNous/new-api/common" @@ -18,33 +16,24 @@ const ( ) func redisEmailVerificationRateLimiter(c *gin.Context) { - ctx := context.Background() - rdb := common.RDB - key := "emailVerification:" + EmailVerificationRateLimitMark + ":" + c.ClientIP() - - count, err := rdb.Incr(ctx, key).Result() + allowed, _, ttlSeconds, err := redisFixedWindowTake( + c.Request.Context(), + redisIPRateLimitKey(EmailVerificationRateLimitMark, c.ClientIP()), + EmailVerificationMaxRequests, + EmailVerificationDuration, + ) if err != nil { - // fallback memoryEmailVerificationRateLimiter(c) return } - - // 第一次设置键时设置过期时间 - if count == 1 { - _ = rdb.Expire(ctx, key, time.Duration(EmailVerificationDuration)*time.Second).Err() - } - - // 检查是否超出限制 - if count <= int64(EmailVerificationMaxRequests) { + if allowed { c.Next() return } - // 获取剩余等待时间 - ttl, err := rdb.TTL(ctx, key).Result() waitSeconds := int64(EmailVerificationDuration) - if err == nil && ttl > 0 { - waitSeconds = int64(ttl.Seconds()) + if ttlSeconds > 0 { + waitSeconds = ttlSeconds } c.JSON(http.StatusTooManyRequests, gin.H{ @@ -70,11 +59,13 @@ func memoryEmailVerificationRateLimiter(c *gin.Context) { } func EmailVerificationRateLimit() gin.HandlerFunc { + // Keep the fallback ready before requests arrive so a concurrent Redis + // outage cannot race the in-memory limiter's first initialization. + inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration) return func(c *gin.Context) { if common.RedisEnabled { redisEmailVerificationRateLimiter(c) } else { - inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration) memoryEmailVerificationRateLimiter(c) } } diff --git a/middleware/model-rate-limit.go b/middleware/model-rate-limit.go index 80a3995df097..87021393205b 100644 --- a/middleware/model-rate-limit.go +++ b/middleware/model-rate-limit.go @@ -19,6 +19,7 @@ import ( const ( ModelRequestRateLimitCountMark = "MRRL" ModelRequestRateLimitSuccessCountMark = "MRRLS" + modelRateLimitTimeFormat = "2006-01-02T15:04:05.000Z" ) // 检查Redis中的请求限制 @@ -41,13 +42,13 @@ func checkRedisRateLimit(ctx context.Context, rdb *redis.Client, key string, max // 检查时间窗口 oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result() - oldTime, err := time.Parse(timeFormat, oldTimeStr) + oldTime, err := time.Parse(modelRateLimitTimeFormat, oldTimeStr) if err != nil { return false, err } - nowTimeStr := time.Now().Format(timeFormat) - nowTime, err := time.Parse(timeFormat, nowTimeStr) + nowTimeStr := time.Now().Format(modelRateLimitTimeFormat) + nowTime, err := time.Parse(modelRateLimitTimeFormat, nowTimeStr) if err != nil { return false, err } @@ -68,7 +69,7 @@ func recordRedisRequest(ctx context.Context, rdb *redis.Client, key string, maxC return } - now := time.Now().Format(timeFormat) + now := time.Now().Format(modelRateLimitTimeFormat) rdb.LPush(ctx, key, now) rdb.LTrim(ctx, key, 0, int64(maxCount-1)) rdb.Expire(ctx, key, time.Duration(setting.ModelRequestRateLimitDurationMinutes)*time.Minute) diff --git a/middleware/rate-limit.go b/middleware/rate-limit.go index d8dd15d9c5d7..a6389161c296 100644 --- a/middleware/rate-limit.go +++ b/middleware/rate-limit.go @@ -2,15 +2,37 @@ package middleware import ( "context" + "errors" "fmt" "net/http" - "time" + "strconv" "github.com/QuantumNous/new-api/common" "github.com/gin-gonic/gin" ) -var timeFormat = "2006-01-02T15:04:05.000Z" +const redisRateLimitNamespace = "rateLimit:v2" + +// Redis rate limiting intentionally uses a fixed window. The single Lua script +// makes increment, expiry, and the limit decision atomic, while retaining the +// simple fixed-window behavior: traffic at a window boundary can burst up to +// twice the configured limit. Do not replace this with a sliding-window ZSET +// unless that externally visible behavior is intentionally changed. +const redisFixedWindowScript = ` +local count = redis.call('INCR', KEYS[1]) +if count == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[2]) +end +local ttl = redis.call('TTL', KEYS[1]) +if ttl < 0 then + redis.call('EXPIRE', KEYS[1], ARGV[2]) + ttl = redis.call('TTL', KEYS[1]) +end +if count > tonumber(ARGV[1]) then + return {0, count, ttl} +end +return {1, count, ttl} +` var inMemoryRateLimiter common.InMemoryRateLimiter @@ -18,49 +40,87 @@ var defNext = func(c *gin.Context) { c.Next() } +func redisIPRateLimitKey(mark string, clientIP string) string { + return fmt.Sprintf("%s:ip:%s:%s", redisRateLimitNamespace, mark, clientIP) +} + +func redisUserRateLimitKey(mark string, userID int) string { + return fmt.Sprintf("%s:user:%s:%d", redisRateLimitNamespace, mark, userID) +} + +func redisReplyInteger(value interface{}) (int64, error) { + switch typed := value.(type) { + case int64: + return typed, nil + case string: + return strconv.ParseInt(typed, 10, 64) + case []byte: + return strconv.ParseInt(string(typed), 10, 64) + default: + return 0, fmt.Errorf("unexpected Redis integer reply type %T", value) + } +} + +func redisFixedWindowTake(ctx context.Context, key string, maxRequestNum int, duration int64) (bool, int64, int64, error) { + if common.RDB == nil { + return false, 0, 0, errors.New("Redis client is not initialized") + } + if key == "" { + return false, 0, 0, errors.New("rate limit key is empty") + } + if maxRequestNum <= 0 { + return false, 0, 0, errors.New("rate limit maximum must be positive") + } + if duration <= 0 { + return false, 0, 0, errors.New("rate limit duration must be positive") + } + + values, err := common.RDB.Eval( + ctx, + redisFixedWindowScript, + []string{key}, + maxRequestNum, + duration, + ).Slice() + if err != nil { + return false, 0, 0, err + } + if len(values) != 3 { + return false, 0, 0, fmt.Errorf("unexpected Redis rate limit reply length %d", len(values)) + } + + allowedValue, err := redisReplyInteger(values[0]) + if err != nil { + return false, 0, 0, err + } + count, err := redisReplyInteger(values[1]) + if err != nil { + return false, 0, 0, err + } + ttlSeconds, err := redisReplyInteger(values[2]) + if err != nil { + return false, 0, 0, err + } + + return allowedValue == 1, count, ttlSeconds, nil +} + func redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) { - ctx := context.Background() - rdb := common.RDB - key := "rateLimit:" + mark + c.ClientIP() - listLength, err := rdb.LLen(ctx, key).Result() + allowed, _, _, err := redisFixedWindowTake( + c.Request.Context(), + redisIPRateLimitKey(mark, c.ClientIP()), + maxRequestNum, + duration, + ) if err != nil { fmt.Println(err.Error()) c.Status(http.StatusInternalServerError) c.Abort() return } - if listLength < int64(maxRequestNum) { - rdb.LPush(ctx, key, time.Now().Format(timeFormat)) - rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) - } else { - oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result() - oldTime, err := time.Parse(timeFormat, oldTimeStr) - if err != nil { - fmt.Println(err) - c.Status(http.StatusInternalServerError) - c.Abort() - return - } - nowTimeStr := time.Now().Format(timeFormat) - nowTime, err := time.Parse(timeFormat, nowTimeStr) - if err != nil { - fmt.Println(err) - c.Status(http.StatusInternalServerError) - c.Abort() - return - } - // time.Since will return negative number! - // See: https://stackoverflow.com/questions/50970900/why-is-time-since-returning-negative-durations-on-windows - if int64(nowTime.Sub(oldTime).Seconds()) < duration { - rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) - c.Status(http.StatusTooManyRequests) - c.Abort() - return - } else { - rdb.LPush(ctx, key, time.Now().Format(timeFormat)) - rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1)) - rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) - } + if !allowed { + c.Status(http.StatusTooManyRequests) + c.Abort() } } @@ -78,12 +138,11 @@ func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gi return func(c *gin.Context) { redisRateLimiter(c, maxRequestNum, duration, mark) } - } else { - // It's safe to call multi times. - inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration) - return func(c *gin.Context) { - memoryRateLimiter(c, maxRequestNum, duration, mark) - } + } + // It's safe to call multi times. + inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration) + return func(c *gin.Context) { + memoryRateLimiter(c, maxRequestNum, duration, mark) } } @@ -122,26 +181,25 @@ func UploadRateLimit() func(c *gin.Context) { func userRateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) { if common.RedisEnabled { return func(c *gin.Context) { - userId := c.GetInt("id") - if userId == 0 { + userID := c.GetInt("id") + if userID == 0 { c.Status(http.StatusUnauthorized) c.Abort() return } - key := fmt.Sprintf("rateLimit:%s:user:%d", mark, userId) - userRedisRateLimiter(c, maxRequestNum, duration, key) + userRedisRateLimiter(c, maxRequestNum, duration, redisUserRateLimitKey(mark, userID)) } } // It's safe to call multi times. inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration) return func(c *gin.Context) { - userId := c.GetInt("id") - if userId == 0 { + userID := c.GetInt("id") + if userID == 0 { c.Status(http.StatusUnauthorized) c.Abort() return } - key := fmt.Sprintf("%s:user:%d", mark, userId) + key := fmt.Sprintf("%s:user:%d", mark, userID) if !inMemoryRateLimiter.Request(key, maxRequestNum, duration) { c.Status(http.StatusTooManyRequests) c.Abort() @@ -153,45 +211,16 @@ func userRateLimitFactory(maxRequestNum int, duration int64, mark string) func(c // userRedisRateLimiter is like redisRateLimiter but accepts a pre-built key // (to support user-ID-based keys). func userRedisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, key string) { - ctx := context.Background() - rdb := common.RDB - listLength, err := rdb.LLen(ctx, key).Result() + allowed, _, _, err := redisFixedWindowTake(c.Request.Context(), key, maxRequestNum, duration) if err != nil { fmt.Println(err.Error()) c.Status(http.StatusInternalServerError) c.Abort() return } - if listLength < int64(maxRequestNum) { - rdb.LPush(ctx, key, time.Now().Format(timeFormat)) - rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) - } else { - oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result() - oldTime, err := time.Parse(timeFormat, oldTimeStr) - if err != nil { - fmt.Println(err) - c.Status(http.StatusInternalServerError) - c.Abort() - return - } - nowTimeStr := time.Now().Format(timeFormat) - nowTime, err := time.Parse(timeFormat, nowTimeStr) - if err != nil { - fmt.Println(err) - c.Status(http.StatusInternalServerError) - c.Abort() - return - } - if int64(nowTime.Sub(oldTime).Seconds()) < duration { - rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) - c.Status(http.StatusTooManyRequests) - c.Abort() - return - } else { - rdb.LPush(ctx, key, time.Now().Format(timeFormat)) - rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1)) - rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) - } + if !allowed { + c.Status(http.StatusTooManyRequests) + c.Abort() } } diff --git a/middleware/rate_limit_test.go b/middleware/rate_limit_test.go new file mode 100644 index 000000000000..b955753b24af --- /dev/null +++ b/middleware/rate_limit_test.go @@ -0,0 +1,223 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/go-redis/redis/v8" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func useRateLimitMiniRedis(t *testing.T) (*miniredis.Miniredis, *redis.Client) { + t.Helper() + + previousRedisEnabled := common.RedisEnabled + previousRedisClient := common.RDB + redisServer := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + require.NoError(t, redisClient.Ping(context.Background()).Err()) + + common.RedisEnabled = true + common.RDB = redisClient + t.Cleanup(func() { + _ = redisClient.Close() + common.RedisEnabled = previousRedisEnabled + common.RDB = previousRedisClient + }) + + return redisServer, redisClient +} + +func performRateLimitRequest(router http.Handler, path string, remoteAddr string) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, path, nil) + request.RemoteAddr = remoteAddr + router.ServeHTTP(recorder, request) + return recorder +} + +func TestRedisIPRateLimiterThresholdTTLAndNamespace(t *testing.T) { + gin.SetMode(gin.TestMode) + redisServer, _ := useRateLimitMiniRedis(t) + + router := gin.New() + require.NoError(t, router.SetTrustedProxies(nil)) + router.GET("/limited", rateLimitFactory(2, 37, "TEST"), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + remoteAddr := "192.0.2.10:12345" + legacyKey := "rateLimit:TEST192.0.2.10" + _, err := redisServer.Push(legacyKey, "legacy-list-entry") + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/limited", remoteAddr).Code) + assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/limited", remoteAddr).Code) + assert.Equal(t, http.StatusTooManyRequests, performRateLimitRequest(router, "/limited", remoteAddr).Code) + + key := redisIPRateLimitKey("TEST", "192.0.2.10") + count, err := redisServer.Get(key) + require.NoError(t, err) + assert.Equal(t, "3", count) + assert.Equal(t, 37*time.Second, redisServer.TTL(key)) + assert.True(t, redisServer.Exists(legacyKey), "the v2 counter must not touch an old list key") +} + +func TestRedisUserRateLimiterUsesSharedFixedWindow(t *testing.T) { + gin.SetMode(gin.TestMode) + redisServer, _ := useRateLimitMiniRedis(t) + + router := gin.New() + router.GET( + "/limited", + func(c *gin.Context) { c.Set("id", 42) }, + userRateLimitFactory(1, 23, "USER"), + func(c *gin.Context) { c.Status(http.StatusNoContent) }, + ) + + assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/limited", "192.0.2.20:12345").Code) + assert.Equal(t, http.StatusTooManyRequests, performRateLimitRequest(router, "/limited", "198.51.100.20:12345").Code) + + key := redisUserRateLimitKey("USER", 42) + assert.True(t, redisServer.Exists(key)) + assert.Equal(t, 23*time.Second, redisServer.TTL(key)) +} + +func TestRedisEmailVerificationRateLimiterPreservesResponseAndTTL(t *testing.T) { + gin.SetMode(gin.TestMode) + redisServer, _ := useRateLimitMiniRedis(t) + + router := gin.New() + require.NoError(t, router.SetTrustedProxies(nil)) + router.GET("/verify", EmailVerificationRateLimit(), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + remoteAddr := "192.0.2.30:12345" + assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/verify", remoteAddr).Code) + assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/verify", remoteAddr).Code) + response := performRateLimitRequest(router, "/verify", remoteAddr) + assert.Equal(t, http.StatusTooManyRequests, response.Code) + assert.JSONEq(t, `{"success":false,"message":"发送过于频繁,请等待 30 秒后再试"}`, response.Body.String()) + + key := redisIPRateLimitKey(EmailVerificationRateLimitMark, "192.0.2.30") + assert.True(t, redisServer.Exists(key)) + assert.Equal(t, time.Duration(EmailVerificationDuration)*time.Second, redisServer.TTL(key)) +} + +func TestRedisFixedWindowIsAtomicUnderConcurrency(t *testing.T) { + redisServer, _ := useRateLimitMiniRedis(t) + const ( + requestCount = 20 + maximumCount = 7 + duration = int64(41) + ) + key := redisIPRateLimitKey("CONCURRENT", "192.0.2.40") + + var allowedCount atomic.Int64 + errorsFound := make(chan error, requestCount) + var waitGroup sync.WaitGroup + waitGroup.Add(requestCount) + for range requestCount { + go func() { + defer waitGroup.Done() + allowed, _, _, err := redisFixedWindowTake(context.Background(), key, maximumCount, duration) + if err != nil { + errorsFound <- err + return + } + if allowed { + allowedCount.Add(1) + } + }() + } + waitGroup.Wait() + close(errorsFound) + for err := range errorsFound { + require.NoError(t, err) + } + + assert.Equal(t, int64(maximumCount), allowedCount.Load()) + count, err := redisServer.Get(key) + require.NoError(t, err) + assert.Equal(t, "20", count) + assert.Equal(t, time.Duration(duration)*time.Second, redisServer.TTL(key)) +} + +func TestRedisFixedWindowResetsAtBoundary(t *testing.T) { + redisServer, _ := useRateLimitMiniRedis(t) + const duration = int64(10) + key := redisIPRateLimitKey("BOUNDARY", "192.0.2.50") + + for range 2 { + allowed, _, _, err := redisFixedWindowTake(context.Background(), key, 2, duration) + require.NoError(t, err) + assert.True(t, allowed) + } + allowed, _, _, err := redisFixedWindowTake(context.Background(), key, 2, duration) + require.NoError(t, err) + assert.False(t, allowed) + + // This reset is intentional fixed-window behavior. A client can consume one + // full allowance immediately before and another immediately after a boundary. + redisServer.FastForward(time.Duration(duration) * time.Second) + for range 2 { + allowed, _, _, err = redisFixedWindowTake(context.Background(), key, 2, duration) + require.NoError(t, err) + assert.True(t, allowed) + } +} + +func TestRedisFixedWindowRepairsCounterWithoutTTL(t *testing.T) { + redisServer, _ := useRateLimitMiniRedis(t) + const duration = int64(29) + key := redisIPRateLimitKey("MISSING-TTL", "192.0.2.51") + redisServer.Set(key, "5") + + allowed, count, ttl, err := redisFixedWindowTake(context.Background(), key, 3, duration) + require.NoError(t, err) + assert.False(t, allowed) + assert.Equal(t, int64(6), count) + assert.Equal(t, duration, ttl) + assert.Equal(t, time.Duration(duration)*time.Second, redisServer.TTL(key)) + + redisServer.FastForward(time.Duration(duration) * time.Second) + assert.False(t, redisServer.Exists(key), "a recovered counter must not remain permanently rate-limited") +} + +func TestRedisFailurePolicies(t *testing.T) { + gin.SetMode(gin.TestMode) + _, redisClient := useRateLimitMiniRedis(t) + require.NoError(t, redisClient.Close()) + + router := gin.New() + require.NoError(t, router.SetTrustedProxies(nil)) + router.GET("/ip", rateLimitFactory(1, 30, "FAIL-IP"), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + router.GET( + "/user", + func(c *gin.Context) { c.Set("id", 7) }, + userRateLimitFactory(1, 30, "FAIL-USER"), + func(c *gin.Context) { c.Status(http.StatusNoContent) }, + ) + router.GET("/email", EmailVerificationRateLimit(), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + ipResponse := performRateLimitRequest(router, "/ip", "192.0.2.60:12345") + assert.Equal(t, http.StatusInternalServerError, ipResponse.Code) + assert.Empty(t, ipResponse.Body.String()) + userResponse := performRateLimitRequest(router, "/user", "192.0.2.61:12345") + assert.Equal(t, http.StatusInternalServerError, userResponse.Code) + assert.Empty(t, userResponse.Body.String()) + assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/email", "192.0.2.62:12345").Code) +} diff --git a/model/user_session.go b/model/user_session.go index 2850ea2c2dda..1003caeba352 100644 --- a/model/user_session.go +++ b/model/user_session.go @@ -17,15 +17,22 @@ const ( UserSessionStatusRevoking = "revoking" UserSessionStatusRevoked = "revoked" - userSessionCacheSchema = 1 + userSessionCacheSchema = 1 + userSessionListLimit = 100 + userSessionRevokeBatchSize = 500 + userSessionCleanupScanLimit = 1000 + userSessionCleanupBatchSize = 500 ) var ( - ErrUserSessionInvalid = errors.New("user session is invalid") - ErrUserSessionInactive = errors.New("user session is inactive") - ErrUserSessionRefreshInvalid = errors.New("user session refresh token is invalid") - ErrUserSessionRefreshRace = errors.New("user session refresh is already in progress") - ErrUserSessionRefreshReuse = errors.New("user session refresh token was reused") + ErrUserSessionInvalid = errors.New("user session is invalid") + ErrUserSessionInactive = errors.New("user session is inactive") + ErrUserSessionRefreshInvalid = errors.New("user session refresh token is invalid") + ErrUserSessionRefreshRace = errors.New("user session refresh is already in progress") + ErrUserSessionRefreshReuse = errors.New("user session refresh token was reused") + ErrUserSessionLimit = errors.New("active user session limit reached") + ErrUserSessionIssuanceLimit = errors.New("user session issuance limit reached") + errUserSessionCacheObservationStale = errors.New("user session cache observation is stale") ) // UserSession is the server-side control plane for short-lived access JWTs. @@ -33,20 +40,20 @@ var ( // refresh secrets are never persisted. type UserSession struct { SID string `json:"sid" gorm:"column:sid;type:varchar(64);primaryKey"` - UserID int `json:"user_id" gorm:"column:user_id;not null;index:idx_user_sessions_user_status_expiry,priority:1"` + UserID int `json:"user_id" gorm:"column:user_id;not null;index:idx_user_sessions_user_status_expiry,priority:1;index:idx_user_sessions_user_created,priority:1"` Version int64 `json:"version" gorm:"type:bigint;not null;default:1"` UserAuthVersion int64 `json:"user_auth_version" gorm:"type:bigint;not null"` - Status string `json:"status" gorm:"type:varchar(16);not null;index:idx_user_sessions_user_status_expiry,priority:2"` + Status string `json:"status" gorm:"type:varchar(16);not null;index:idx_user_sessions_user_status_expiry,priority:2;index:idx_user_sessions_status_revoked,priority:1"` RefreshHash string `json:"-" gorm:"type:char(64);not null"` PreviousRefreshHash string `json:"-" gorm:"type:char(64)"` PreviousValidUntil int64 `json:"-" gorm:"type:bigint;not null;default:0"` LoginMethod string `json:"login_method" gorm:"type:varchar(32);not null"` IP string `json:"ip" gorm:"type:varchar(64)"` UserAgent string `json:"user_agent" gorm:"type:text"` - CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at;index:idx_user_sessions_user_created,priority:2"` LastActiveAt int64 `json:"last_active_at" gorm:"type:bigint;not null;column:last_active_at"` - ExpiresAt int64 `json:"expires_at" gorm:"type:bigint;not null;column:expires_at;index:idx_user_sessions_user_status_expiry,priority:3"` - RevokedAt int64 `json:"revoked_at,omitempty" gorm:"type:bigint;not null;default:0;column:revoked_at"` + ExpiresAt int64 `json:"expires_at" gorm:"type:bigint;not null;column:expires_at;index:idx_user_sessions_user_status_expiry,priority:3;index:idx_user_sessions_expires_at"` + RevokedAt int64 `json:"revoked_at,omitempty" gorm:"type:bigint;not null;default:0;column:revoked_at;index:idx_user_sessions_status_revoked,priority:2"` RevokedReason string `json:"revoked_reason,omitempty" gorm:"type:varchar(64);column:revoked_reason"` } @@ -113,8 +120,13 @@ func userSessionCacheKey(sid string) string { return "auth:session:" + digest } +func userSessionCacheDeadline() time.Time { + return time.Now().Add(time.Duration(userCacheTTLSeconds()) * time.Second) +} + func CreateUserSession(session *UserSession) error { - if session == nil || session.SID == "" || session.UserID <= 0 || session.UserAuthVersion <= 0 || session.RefreshHash == "" || session.ExpiresAt <= time.Now().Unix() { + now := time.Now().Unix() + if session == nil || session.SID == "" || session.UserID <= 0 || session.UserAuthVersion <= 0 || session.RefreshHash == "" || session.ExpiresAt <= now { return ErrUserSessionInvalid } if session.Version <= 0 { @@ -127,17 +139,56 @@ func CreateUserSession(session *UserSession) error { return ErrUserSessionInvalid } if session.LastActiveAt == 0 { - session.LastActiveAt = time.Now().Unix() + session.LastActiveAt = now } + if session.CreatedAt == 0 { + session.CreatedAt = now + } + cacheDeadline := userSessionCacheDeadline() if err := DB.Create(session).Error; err != nil { return err } - if err := writeUserSessionCache(session.cacheEntry()); err != nil { + if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil { + if errors.Is(err, errUserSessionCacheObservationStale) { + return confirmUserSessionActiveSnapshot(session) + } + if errors.Is(err, ErrUserSessionInactive) { + return err + } common.SysLog("failed to populate newly created user session cache: " + err.Error()) } return nil } +func CountActiveUserSessions(userID int, now int64) (int64, error) { + if userID <= 0 { + return 0, ErrUserSessionInvalid + } + if now <= 0 { + now = time.Now().Unix() + } + var count int64 + err := DB.Model(&UserSession{}). + Where("user_id = ? AND status = ? AND expires_at > ?", userID, UserSessionStatusActive, now). + Count(&count).Error + return count, err +} + +// CountUserSessionsCreatedSince counts every issued row, regardless of its +// current status or expiry. userID zero selects the global count. +func CountUserSessionsCreatedSince(userID int, createdAfter int64) (int64, error) { + if userID < 0 || createdAfter <= 0 { + return 0, ErrUserSessionInvalid + } + query := DB.Model(&UserSession{}).Where("created_at > ?", createdAfter) + if userID > 0 { + query = query.Where("user_id = ?", userID) + } + var count int64 + err := query.Count(&count).Error + return count, err +} + func GetUserSessionBySID(sid string) (*UserSession, error) { if sid == "" { return nil, ErrUserSessionInvalid @@ -165,6 +216,7 @@ func GetUserSessionCached(sid string) (*UserSession, error) { } } + cacheDeadline := userSessionCacheDeadline() session, err := GetUserSessionBySID(sid) if err != nil { return nil, err @@ -174,12 +226,18 @@ func GetUserSessionCached(sid string) (*UserSession, error) { if common.RedisEnabled { entry := session.cacheEntry() entry.Status = UserSessionStatusRevoked - _ = writeUserSessionCache(entry) + _ = writeUserSessionCache(entry, time.Time{}) } return nil, ErrUserSessionInactive } if common.RedisEnabled { - if err := writeUserSessionCache(session.cacheEntry()); err != nil { + if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil { + if errors.Is(err, errUserSessionCacheObservationStale) { + if confirmErr := confirmUserSessionActiveSnapshot(session); confirmErr != nil { + return nil, confirmErr + } + return session, nil + } if errors.Is(err, ErrUserSessionInactive) { return nil, err } @@ -203,13 +261,48 @@ func getUserSessionCache(sid string) (*userSessionCacheEntry, error) { return &entry, nil } -func writeUserSessionCache(entry *userSessionCacheEntry) error { +// writeUserSessionCache writes a bounded Session snapshot. Active snapshots +// must carry a deadline captured immediately before their authoritative +// database read or mutation. Delayed fills inherit the unspent portion of that +// window, so a stale active snapshot cannot outlive a short deny tombstone and +// reactivate a revoked Session after the tombstone expires. Deny states pass a +// zero deadline because their TTL starts when they are published. +func writeUserSessionCache(entry *userSessionCacheEntry, cacheDeadline time.Time) error { if entry == nil || !common.RedisEnabled { return nil } - ttl := entry.ExpiresAt - time.Now().Unix() - if ttl <= 0 { - ttl = 1 + now := time.Now() + sessionExpiresAt := time.Unix(entry.ExpiresAt, 0) + sessionTTL := sessionExpiresAt.Sub(now) + var redisExpiration int64 + if entry.Status == UserSessionStatusActive { + if cacheDeadline.IsZero() { + return ErrUserSessionInvalid + } + cacheTTL := cacheDeadline.Sub(now) + if cacheTTL <= 0 { + return errUserSessionCacheObservationStale + } + if sessionTTL <= 0 { + return ErrUserSessionInactive + } + cacheExpiresAt := cacheDeadline + if sessionExpiresAt.Before(cacheExpiresAt) { + cacheExpiresAt = sessionExpiresAt + } + if cacheExpiresAt.Sub(now) < time.Millisecond { + return errUserSessionCacheObservationStale + } + redisExpiration = cacheExpiresAt.UnixMilli() + } else { + ttl := min(sessionTTL, time.Duration(userCacheTTLSeconds())*time.Second) + if ttl <= 0 { + ttl = time.Second + } + redisExpiration = ttl.Milliseconds() + if redisExpiration <= 0 { + redisExpiration = 1 + } } entry.CacheSchema = userSessionCacheSchema const script = ` @@ -227,12 +320,16 @@ redis.call('HSET', KEYS[1], 'LoginMethod', ARGV[6], 'IP', ARGV[7], 'UserAgent', ARGV[8], 'CreatedAt', ARGV[9], 'LastActiveAt', ARGV[10], 'ExpiresAt', ARGV[11], 'RevokedAt', ARGV[12], 'RevokedReason', ARGV[13], 'CacheSchema', ARGV[14]) -redis.call('EXPIRE', KEYS[1], ARGV[15]) +if ARGV[5] == 'active' then + redis.call('PEXPIREAT', KEYS[1], ARGV[15]) +else + redis.call('PEXPIRE', KEYS[1], ARGV[15]) +end return 1` result, err := common.RDB.Eval(context.Background(), script, []string{userSessionCacheKey(entry.SID)}, entry.SID, entry.UserID, entry.Version, entry.UserAuthVersion, entry.Status, entry.LoginMethod, entry.IP, entry.UserAgent, entry.CreatedAt, entry.LastActiveAt, - entry.ExpiresAt, entry.RevokedAt, entry.RevokedReason, entry.CacheSchema, ttl, + entry.ExpiresAt, entry.RevokedAt, entry.RevokedReason, entry.CacheSchema, redisExpiration, ).Int() if err != nil { return err @@ -240,6 +337,41 @@ return 1` if result == 0 { return ErrUserSessionInactive } + if entry.Status == UserSessionStatusActive { + completedAt := time.Now() + if !completedAt.Before(cacheDeadline) { + return errUserSessionCacheObservationStale + } + if !completedAt.Before(sessionExpiresAt) { + return ErrUserSessionInactive + } + } + return nil +} + +func confirmUserSessionActiveSnapshot(session *UserSession) error { + if session == nil || session.SID == "" || session.UserID <= 0 || session.Version <= 0 || session.UserAuthVersion <= 0 { + return ErrUserSessionInvalid + } + var count int64 + err := DB.Model(&UserSession{}). + Where( + "sid = ? AND user_id = ? AND status = ? AND revoked_at = ? AND expires_at > ? AND version = ? AND user_auth_version = ?", + session.SID, + session.UserID, + UserSessionStatusActive, + 0, + time.Now().Unix(), + session.Version, + session.UserAuthVersion, + ). + Count(&count).Error + if err != nil { + return err + } + if count != 1 { + return ErrUserSessionInactive + } return nil } @@ -251,10 +383,10 @@ func writeUserSessionDenyFence(session *UserSession, status string, now int64, r entry.Status = status entry.RevokedAt = now entry.RevokedReason = reason - return writeUserSessionCache(entry) + return writeUserSessionCache(entry, time.Time{}) } -func ListActiveUserSessions(userID int, now int64) ([]UserSession, error) { +func ListActiveUserSessions(userID int, currentSID string, now int64) ([]UserSession, error) { if userID <= 0 { return nil, ErrUserSessionInvalid } @@ -268,10 +400,41 @@ func ListActiveUserSessions(userID int, now int64) ([]UserSession, error) { if authVersion <= 0 { return nil, ErrUserSessionInvalid } - var sessions []UserSession - err := DB.Where("user_id = ? AND user_auth_version = ? AND status = ? AND expires_at > ?", userID, authVersion, UserSessionStatusActive, now). - Order("last_active_at DESC").Order("created_at DESC").Find(&sessions).Error - return sessions, err + sessions := make([]UserSession, 0, userSessionListLimit) + if currentSID != "" { + var current []UserSession + if err := DB.Where( + "user_id = ? AND user_auth_version = ? AND status = ? AND expires_at > ? AND sid = ?", + userID, + authVersion, + UserSessionStatusActive, + now, + currentSID, + ).Limit(1).Find(¤t).Error; err != nil { + return nil, err + } + if len(current) == 1 { + sessions = append(sessions, current[0]) + } + } + remainingLimit := userSessionListLimit - len(sessions) + + otherQuery := DB.Where( + "user_id = ? AND user_auth_version = ? AND status = ? AND expires_at > ?", + userID, + authVersion, + UserSessionStatusActive, + now, + ) + if currentSID != "" { + otherQuery = otherQuery.Where("sid <> ?", currentSID) + } + var others []UserSession + if err := otherQuery.Order("last_active_at DESC").Order("created_at DESC").Limit(remainingLimit).Find(&others).Error; err != nil { + return nil, err + } + sessions = append(sessions, others...) + return sessions, nil } // RotateUserSessionRefresh atomically rotates HMAC digests. The UPDATE itself @@ -291,6 +454,7 @@ func RotateUserSessionRefresh(userID int, sid, presentedHash, nextHash string, n return nil, ErrUserSessionInvalid } for range 3 { + cacheDeadline := userSessionCacheDeadline() var session UserSession if err := DB.Where("sid = ? AND user_id = ?", sid, userID).First(&session).Error; err != nil { return nil, err @@ -319,8 +483,16 @@ func RotateUserSessionRefresh(userID int, sid, presentedHash, nextHash string, n session.PreviousValidUntil = now + graceSeconds session.RefreshHash = nextHash session.LastActiveAt = now - if err := writeUserSessionCache(session.cacheEntry()); err != nil && !errors.Is(err, ErrUserSessionInactive) { - common.SysLog("failed to update rotated user session cache: " + err.Error()) + if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil { + if errors.Is(err, errUserSessionCacheObservationStale) { + if confirmErr := confirmUserSessionActiveSnapshot(&session); confirmErr != nil { + return nil, confirmErr + } + } else if errors.Is(err, ErrUserSessionInactive) { + return nil, err + } else { + common.SysLog("failed to update rotated user session cache: " + err.Error()) + } } return &session, nil } @@ -355,7 +527,7 @@ func RotateUserSessionRefresh(userID int, sid, presentedHash, nextHash string, n session.Status = UserSessionStatusRevoked session.RevokedAt = now session.RevokedReason = "refresh_reuse" - if err := writeUserSessionCache(session.cacheEntry()); err != nil { + if err := writeUserSessionCache(session.cacheEntry(), time.Time{}); err != nil { common.SysLog("failed to cache refresh-reuse session revoke: " + err.Error()) } return nil, ErrUserSessionRefreshReuse @@ -409,7 +581,7 @@ func RevokeUserSession(userID int, sid, reason string) (bool, error) { candidate.Status = UserSessionStatusRevoked candidate.RevokedAt = now candidate.RevokedReason = reason - if err := writeUserSessionCache(candidate.cacheEntry()); err != nil { + if err := writeUserSessionCache(candidate.cacheEntry(), time.Time{}); err != nil { common.SysLog("failed to finalize user session revoke tombstone: " + err.Error()) } } @@ -465,7 +637,7 @@ func RevokeUserSessionByRefreshHash(sid, presentedHash, reason string) (bool, er return false, err } if revoked { - if err := writeUserSessionCache(session.cacheEntry()); err != nil { + if err := writeUserSessionCache(session.cacheEntry(), time.Time{}); err != nil { common.SysLog("failed to finalize refresh-authenticated session revoke tombstone: " + err.Error()) } } @@ -479,6 +651,7 @@ func AdvanceUserSessionAuthVersion(userID int, sid string, expectedSessionVersio if userID <= 0 || sid == "" || expectedSessionVersion <= 0 || expectedUserAuthVersion <= 0 || nextUserAuthVersion <= expectedUserAuthVersion { return nil, ErrUserSessionInvalid } + cacheDeadline := userSessionCacheDeadline() now := time.Now().Unix() var session UserSession err := DB.Transaction(func(tx *gorm.DB) error { @@ -510,8 +683,14 @@ func AdvanceUserSessionAuthVersion(userID int, sid string, expectedSessionVersio if err != nil { return nil, err } - if err := writeUserSessionCache(session.cacheEntry()); err != nil { - return nil, err + if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil { + if errors.Is(err, errUserSessionCacheObservationStale) { + if confirmErr := confirmUserSessionActiveSnapshot(&session); confirmErr != nil { + return nil, confirmErr + } + } else { + return nil, err + } } return &session, nil } @@ -529,65 +708,157 @@ func revokeUserSessions(userID int, excludedSID, reason string) (int64, error) { return 0, ErrUserSessionInvalid } now := time.Now().Unix() - query := DB.Where("user_id = ? AND status = ? AND expires_at > ?", userID, UserSessionStatusActive, now) - if excludedSID != "" { - query = query.Where("sid <> ?", excludedSID) - } - var candidates []UserSession - if err := query.Find(&candidates).Error; err != nil { - return 0, err - } - for i := range candidates { - if err := writeUserSessionDenyFence(&candidates[i], UserSessionStatusRevoking, now, reason); err != nil { - return 0, err + var totalAffected int64 + for { + query := DB.Where("user_id = ? AND status = ? AND expires_at > ?", userID, UserSessionStatusActive, now) + if excludedSID != "" { + query = query.Where("sid <> ?", excludedSID) + } + var candidates []UserSession + if err := query.Order("sid").Limit(userSessionRevokeBatchSize).Find(&candidates).Error; err != nil { + return totalAffected, err + } + if len(candidates) == 0 { + return totalAffected, nil + } + for i := range candidates { + if err := writeUserSessionDenyFence(&candidates[i], UserSessionStatusRevoking, now, reason); err != nil { + return totalAffected, err + } + } + + sids := make([]string, 0, len(candidates)) + for i := range candidates { + sids = append(sids, candidates[i].SID) + } + var affected int64 + var revoked []UserSession + err := DB.Transaction(func(tx *gorm.DB) error { + if err := lockForUpdate(tx).Where("sid IN ? AND status = ?", sids, UserSessionStatusActive).Find(&revoked).Error; err != nil { + return err + } + if len(revoked) == 0 { + return nil + } + lockedSIDs := make([]string, 0, len(revoked)) + for i := range revoked { + lockedSIDs = append(lockedSIDs, revoked[i].SID) + } + result := tx.Model(&UserSession{}).Where("sid IN ? AND status = ?", lockedSIDs, UserSessionStatusActive).Updates(map[string]interface{}{ + "status": UserSessionStatusRevoked, + "revoked_at": now, + "revoked_reason": reason, + }) + affected = result.RowsAffected + return result.Error + }) + if err != nil { + return totalAffected, err + } + totalAffected += affected + for i := range revoked { + revoked[i].Status = UserSessionStatusRevoked + revoked[i].RevokedAt = now + revoked[i].RevokedReason = reason + if err := writeUserSessionCache(revoked[i].cacheEntry(), time.Time{}); err != nil { + common.SysLog("failed to finalize bulk user session revoke tombstone: " + err.Error()) + } } } - if len(candidates) == 0 { - return 0, nil +} + +func DeleteExpiredUserSessions(now int64) error { + if now <= 0 { + now = time.Now().Unix() } + if common.UserSessionRevokedRetentionDays <= 0 || common.UserSessionIssuanceWindowSeconds <= 0 { + return ErrUserSessionInvalid + } + issuanceCutoff := now - common.UserSessionIssuanceWindowSeconds + revokedBefore := now - int64(common.UserSessionRevokedRetentionDays)*24*60*60 + return deleteExpiredUserSessionsBefore(now, issuanceCutoff, revokedBefore) +} - sids := make([]string, 0, len(candidates)) - for i := range candidates { - sids = append(sids, candidates[i].SID) +func DeleteOldRevokedUserSessions(now int64) error { + if now <= 0 { + now = time.Now().Unix() } - var affected int64 - err := DB.Transaction(func(tx *gorm.DB) error { - var locked []UserSession - if err := lockForUpdate(tx).Where("sid IN ? AND status = ?", sids, UserSessionStatusActive).Find(&locked).Error; err != nil { + if common.UserSessionRevokedRetentionDays <= 0 || common.UserSessionIssuanceWindowSeconds <= 0 { + return ErrUserSessionInvalid + } + issuanceCutoff := now - common.UserSessionIssuanceWindowSeconds + revokedBefore := now - int64(common.UserSessionRevokedRetentionDays)*24*60*60 + return deleteRevokedUserSessionsBefore(revokedBefore, issuanceCutoff) +} + +func deleteExpiredUserSessionsBefore(expiredBefore, issuanceCutoff, revokedBefore int64) error { + for { + var sids []string + if err := DB.Model(&UserSession{}). + Where( + "expires_at < ? AND created_at <= ? AND (status <> ? OR revoked_at <= 0 OR revoked_at < ?)", + expiredBefore, + issuanceCutoff, + UserSessionStatusRevoked, + revokedBefore, + ). + Order("expires_at").Limit(userSessionCleanupScanLimit).Pluck("sid", &sids).Error; err != nil { return err } - if len(locked) == 0 { + if len(sids) == 0 { return nil } - lockedSIDs := make([]string, 0, len(locked)) - for i := range locked { - lockedSIDs = append(lockedSIDs, locked[i].SID) - } - result := tx.Model(&UserSession{}).Where("sid IN ? AND status = ?", lockedSIDs, UserSessionStatusActive).Updates(map[string]interface{}{ - "status": UserSessionStatusRevoked, - "revoked_at": now, - "revoked_reason": reason, - }) - affected = result.RowsAffected - return result.Error - }) - if err != nil { - return 0, err - } - for i := range candidates { - candidates[i].Status = UserSessionStatusRevoked - candidates[i].RevokedAt = now - candidates[i].RevokedReason = reason - if err := writeUserSessionCache(candidates[i].cacheEntry()); err != nil { - common.SysLog("failed to finalize bulk user session revoke tombstone: " + err.Error()) + for start := 0; start < len(sids); start += userSessionCleanupBatchSize { + end := start + userSessionCleanupBatchSize + if end > len(sids) { + end = len(sids) + } + if err := DB.Where("sid IN ?", sids[start:end]). + Where( + "expires_at < ? AND created_at <= ? AND (status <> ? OR revoked_at <= 0 OR revoked_at < ?)", + expiredBefore, + issuanceCutoff, + UserSessionStatusRevoked, + revokedBefore, + ). + Delete(&UserSession{}).Error; err != nil { + return err + } } } - return affected, nil } -func DeleteExpiredUserSessions(now int64) error { - if now <= 0 { - now = time.Now().Unix() +func deleteRevokedUserSessionsBefore(revokedBefore, issuanceCutoff int64) error { + for { + var sids []string + if err := DB.Model(&UserSession{}). + Where( + "status = ? AND revoked_at > 0 AND revoked_at < ? AND created_at <= ?", + UserSessionStatusRevoked, + revokedBefore, + issuanceCutoff, + ). + Order("revoked_at").Limit(userSessionCleanupScanLimit).Pluck("sid", &sids).Error; err != nil { + return err + } + if len(sids) == 0 { + return nil + } + for start := 0; start < len(sids); start += userSessionCleanupBatchSize { + end := start + userSessionCleanupBatchSize + if end > len(sids) { + end = len(sids) + } + if err := DB.Where("sid IN ?", sids[start:end]). + Where( + "status = ? AND revoked_at > 0 AND revoked_at < ? AND created_at <= ?", + UserSessionStatusRevoked, + revokedBefore, + issuanceCutoff, + ). + Delete(&UserSession{}).Error; err != nil { + return err + } + } } - return DB.Where("expires_at < ?", now).Delete(&UserSession{}).Error } diff --git a/model/user_session_test.go b/model/user_session_test.go index f35958500506..25ad42e80f4f 100644 --- a/model/user_session_test.go +++ b/model/user_session_test.go @@ -1,27 +1,83 @@ package model import ( + "context" "errors" "fmt" "testing" "time" "github.com/QuantumNous/new-api/common" + "github.com/alicebob/miniredis/v2" + "github.com/go-redis/redis/v8" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) +type setMiniRedisTimeOnEvalHook struct { + server *miniredis.Miniredis + at time.Time +} + +func (hook setMiniRedisTimeOnEvalHook) BeforeProcess(ctx context.Context, cmd redis.Cmder) (context.Context, error) { + if cmd.Name() == "eval" { + hook.server.SetTime(hook.at) + } + return ctx, nil +} + +func (setMiniRedisTimeOnEvalHook) AfterProcess(context.Context, redis.Cmder) error { + return nil +} + +func (setMiniRedisTimeOnEvalHook) BeforeProcessPipeline(ctx context.Context, _ []redis.Cmder) (context.Context, error) { + return ctx, nil +} + +func (setMiniRedisTimeOnEvalHook) AfterProcessPipeline(context.Context, []redis.Cmder) error { + return nil +} + func setupUserSessionTest(t *testing.T) { t.Helper() - require.NoError(t, DB.AutoMigrate(&UserSession{})) + require.NoError(t, DB.AutoMigrate(&User{}, &UserSession{})) require.NoError(t, DB.Exec("DELETE FROM user_sessions").Error) oldRedisEnabled := common.RedisEnabled + oldActiveLimit := common.UserSessionActiveLimit + oldIssuanceLimit := common.UserSessionIssuanceLimit + oldIssuanceWindow := common.UserSessionIssuanceWindowSeconds + oldRevokedRetention := common.UserSessionRevokedRetentionDays common.RedisEnabled = false + common.UserSessionActiveLimit = common.DefaultUserSessionActiveLimit + common.UserSessionIssuanceLimit = common.DefaultUserSessionIssuanceLimit + common.UserSessionIssuanceWindowSeconds = int64(common.DefaultUserSessionIssuanceWindowSeconds) + common.UserSessionRevokedRetentionDays = common.DefaultUserSessionRevokedRetentionDays t.Cleanup(func() { common.RedisEnabled = oldRedisEnabled + common.UserSessionActiveLimit = oldActiveLimit + common.UserSessionIssuanceLimit = oldIssuanceLimit + common.UserSessionIssuanceWindowSeconds = oldIssuanceWindow + common.UserSessionRevokedRetentionDays = oldRevokedRetention }) } +func createUserSessionTestUser(t *testing.T, userID int, authVersion int64) { + t.Helper() + user := User{ + Id: userID, + Username: fmt.Sprintf("user-session-%d", userID), + Password: "unused", + Status: common.UserStatusEnabled, + Role: common.RoleCommonUser, + Group: "default", + AffCode: fmt.Sprintf("session-aff-%d", userID), + AuthVersion: authVersion, + } + require.NoError(t, DB.Create(&user).Error) + t.Cleanup(func() { _ = DB.Unscoped().Delete(&User{}, userID).Error }) +} + func newTestUserSession(sid string, userID int, now int64) *UserSession { return &UserSession{ SID: sid, @@ -39,6 +95,123 @@ func newTestUserSession(sid string, userID int, now int64) *UserSession { } } +func TestUserSessionCacheTTLUsesShortCacheWindow(t *testing.T) { + setupUserSessionTest(t) + server := useUserCacheMiniRedis(t) + now := time.Now().Unix() + tests := []struct { + name string + status string + expiresAt int64 + wantMaxTTL time.Duration + }{ + {name: "active", status: UserSessionStatusActive, expiresAt: now + 300, wantMaxTTL: 2 * time.Second}, + {name: "revoking", status: UserSessionStatusRevoking, expiresAt: now + 300, wantMaxTTL: 2 * time.Second}, + {name: "revoked", status: UserSessionStatusRevoked, expiresAt: now + 300, wantMaxTTL: 2 * time.Second}, + {name: "already expired", status: UserSessionStatusRevoked, expiresAt: now - 1, wantMaxTTL: time.Second}, + } + + for index, test := range tests { + sid := fmt.Sprintf("short-cache-ttl-%d", index) + entry := newTestUserSession(sid, 1100+index, now).cacheEntry() + entry.Status = test.status + entry.ExpiresAt = test.expiresAt + if test.status != UserSessionStatusActive { + entry.RevokedAt = now + } + + cacheDeadline := time.Time{} + if test.status == UserSessionStatusActive { + cacheDeadline = userSessionCacheDeadline() + } + require.NoError(t, writeUserSessionCache(entry, cacheDeadline), test.name) + ttl := server.TTL(userSessionCacheKey(sid)) + assert.Positive(t, ttl, test.name) + assert.LessOrEqual(t, ttl, test.wantMaxTTL, test.name) + } + + initialTTL := server.TTL(userSessionCacheKey("short-cache-ttl-0")) + server.FastForward(time.Second) + _, err := getUserSessionCache("short-cache-ttl-0") + require.NoError(t, err) + remainingTTL := server.TTL(userSessionCacheKey("short-cache-ttl-0")) + assert.Positive(t, remainingTTL) + assert.LessOrEqual(t, remainingTTL, initialTTL-time.Second, "cache reads must not renew the bounded TTL") + + common.SyncFrequency = 10 + nearExpiry := newTestUserSession("short-cache-ttl-near-expiry", 1199, now).cacheEntry() + nearExpiry.ExpiresAt = time.Now().Add(2 * time.Second).Unix() + nearExpiryDeadline := userSessionCacheDeadline() + remainingLifetime := time.Until(time.Unix(nearExpiry.ExpiresAt, 0)) + require.NoError(t, writeUserSessionCache(nearExpiry, nearExpiryDeadline)) + nearExpiryTTL := server.TTL(userSessionCacheKey(nearExpiry.SID)) + assert.Positive(t, nearExpiryTTL) + assert.LessOrEqual(t, nearExpiryTTL, remainingLifetime, "cache TTL must not exceed the Session remaining lifetime") + + common.SyncFrequency = 0 + fallback := newTestUserSession("short-cache-ttl-fallback", 1200, now).cacheEntry() + fallback.ExpiresAt = now + 300 + require.NoError(t, writeUserSessionCache(fallback, userSessionCacheDeadline())) + fallbackTTL := server.TTL(userSessionCacheKey(fallback.SID)) + assert.Greater(t, fallbackTTL, 59*time.Second) + assert.LessOrEqual(t, fallbackTTL, 60*time.Second, "non-positive cache frequency must use the existing 60-second fallback") +} + +func TestStaleActiveSessionCacheFillCannotRestartWindowAfterDenyExpires(t *testing.T) { + setupUserSessionTest(t) + server := useUserCacheMiniRedis(t) + now := time.Now().Unix() + active := newTestUserSession("stale-active-cache-fill", 1201, now).cacheEntry() + denied := *active + denied.Status = UserSessionStatusRevoked + denied.RevokedAt = now + denied.RevokedReason = "test-revoke" + + require.NoError(t, writeUserSessionCache(&denied, time.Time{})) + cacheKey := userSessionCacheKey(active.SID) + assert.True(t, server.Exists(cacheKey)) + server.FastForward(3 * time.Second) + assert.False(t, server.Exists(cacheKey), "the short deny tombstone must have expired in this race setup") + + err := writeUserSessionCache(active, time.Now().Add(-time.Millisecond)) + assert.ErrorIs(t, err, errUserSessionCacheObservationStale) + assert.False(t, server.Exists(cacheKey), "a delayed pre-revoke active snapshot must not restart a fresh cache window") +} + +func TestActiveSessionCacheFillUsesRemainingObservationWindow(t *testing.T) { + setupUserSessionTest(t) + server := useUserCacheMiniRedis(t) + now := time.Now().Unix() + entry := newTestUserSession("bounded-active-cache-fill", 1202, now).cacheEntry() + deadline := time.Now().Add(1500 * time.Millisecond) + + require.NoError(t, writeUserSessionCache(entry, deadline)) + ttl := server.TTL(userSessionCacheKey(entry.SID)) + assert.Positive(t, ttl) + assert.LessOrEqual(t, ttl, 1500*time.Millisecond, "a delayed fill must inherit only the unused observation window") +} + +func TestSessionCacheLuaUsesAbsoluteActiveAndRelativeDenyExpiry(t *testing.T) { + setupUserSessionTest(t) + server := useUserCacheMiniRedis(t) + now := time.Now().Unix() + deadline := time.Now().Add(10 * time.Second) + common.RDB.AddHook(setMiniRedisTimeOnEvalHook{server: server, at: deadline.Add(time.Second)}) + + active := newTestUserSession("delayed-active-cache-eval", 1203, now).cacheEntry() + require.NoError(t, writeUserSessionCache(active, deadline)) + assert.False(t, server.Exists(userSessionCacheKey(active.SID)), "an active fill executed after its absolute deadline must not recreate the cache") + + denied := newTestUserSession("delayed-deny-cache-eval", 1204, now).cacheEntry() + denied.Status = UserSessionStatusRevoked + denied.RevokedAt = now + denied.RevokedReason = "test-revoke" + require.NoError(t, writeUserSessionCache(denied, time.Time{})) + denyTTL := server.TTL(userSessionCacheKey(denied.SID)) + assert.Positive(t, denyTTL) + assert.LessOrEqual(t, denyTTL, 2*time.Second, "a delayed deny publication must receive a full relative short TTL at Redis execution") +} + func TestUserSessionCreateListAndRevokeOne(t *testing.T) { setupUserSessionTest(t) now := time.Now().Unix() @@ -50,10 +223,10 @@ func TestUserSessionCreateListAndRevokeOne(t *testing.T) { require.NoError(t, CreateUserSession(first)) require.NoError(t, CreateUserSession(second)) - sessions, err := ListActiveUserSessions(1001, now) + sessions, err := ListActiveUserSessions(1001, first.SID, now) require.NoError(t, err) require.Len(t, sessions, 2) - assert.Equal(t, second.SID, sessions[0].SID) + assert.Equal(t, first.SID, sessions[0].SID) revoked, err := RevokeUserSession(1001, first.SID, "user_revoked") require.NoError(t, err) @@ -72,6 +245,7 @@ func TestUserSessionCreateListAndRevokeOne(t *testing.T) { func TestRotateUserSessionRefreshRaceAndReuse(t *testing.T) { setupUserSessionTest(t) now := time.Now().Unix() + createUserSessionTestUser(t, 1002, 1) session := newTestUserSession("rotate-session", 1002, now) require.NoError(t, CreateUserSession(session)) @@ -100,8 +274,14 @@ func TestRotateUserSessionRefreshRaceAndReuse(t *testing.T) { func TestRevokeOtherUserSessionsKeepsCurrent(t *testing.T) { setupUserSessionTest(t) now := time.Now().Unix() + createUserSessionTestUser(t, 1003, 1) + createUserSessionTestUser(t, 1004, 1) for _, sid := range []string{"current-session", "other-one", "other-two"} { - require.NoError(t, CreateUserSession(newTestUserSession(sid, 1003, now))) + session := newTestUserSession(sid, 1003, now) + if sid == "other-one" { + session.UserAuthVersion = 99 + } + require.NoError(t, CreateUserSession(session)) } require.NoError(t, CreateUserSession(newTestUserSession("different-user", 1004, now))) @@ -114,6 +294,9 @@ func TestRevokeOtherUserSessionsKeepsCurrent(t *testing.T) { assert.Equal(t, UserSessionStatusActive, current.Status) _, err = GetUserSessionCached("other-one") assert.True(t, errors.Is(err, ErrUserSessionInactive)) + stale, err := GetUserSessionBySID("other-one") + require.NoError(t, err) + assert.Equal(t, UserSessionStatusRevoked, stale.Status, "revocation must include active sessions from stale auth versions") different, err := GetUserSessionCached("different-user") require.NoError(t, err) assert.Equal(t, 1004, different.UserID) @@ -122,6 +305,7 @@ func TestRevokeOtherUserSessionsKeepsCurrent(t *testing.T) { func TestRevokeUserSessionByRefreshHashRequiresSecret(t *testing.T) { setupUserSessionTest(t) now := time.Now().Unix() + createUserSessionTestUser(t, 1005, 1) session := newTestUserSession("refresh-logout-session", 1005, now) require.NoError(t, CreateUserSession(session)) @@ -139,6 +323,202 @@ func TestRevokeUserSessionByRefreshHashRequiresSecret(t *testing.T) { assert.ErrorIs(t, err, ErrUserSessionInactive) } +func TestUserSessionGrowthCountsUseBroadActiveAndStrictIssuancePredicates(t *testing.T) { + setupUserSessionTest(t) + now := time.Now().Unix() + createUserSessionTestUser(t, 1006, 7) + rows := []UserSession{ + *newTestUserSession("count-current-version", 1006, now-10), + *newTestUserSession("count-stale-version", 1006, now-9), + *newTestUserSession("count-expired", 1006, now-8), + *newTestUserSession("count-revoked", 1006, now-7), + *newTestUserSession("count-cutoff", 1006, now-3600), + } + rows[0].UserAuthVersion = 7 + rows[1].UserAuthVersion = 2 + rows[2].UserAuthVersion = 7 + rows[2].ExpiresAt = now + rows[3].UserAuthVersion = 7 + rows[3].Status = UserSessionStatusRevoked + rows[3].RevokedAt = now - 1 + rows[4].UserAuthVersion = 7 + rows[4].CreatedAt = now - 3600 + rows[4].ExpiresAt = now + require.NoError(t, DB.Create(&rows).Error) + + activeCount, err := CountActiveUserSessions(1006, now) + require.NoError(t, err) + assert.Equal(t, int64(2), activeCount, "active count includes stale auth versions but excludes expired and revoked rows") + + issuedCount, err := CountUserSessionsCreatedSince(1006, now-3600) + require.NoError(t, err) + assert.Equal(t, int64(4), issuedCount, "issuance count includes every status and uses a strict cutoff") + globalCount, err := CountUserSessionsCreatedSince(0, now-3600) + require.NoError(t, err) + assert.Equal(t, issuedCount, globalCount) +} + +func TestListActiveUserSessionsKeepsCurrentAndBoundsOtherSessions(t *testing.T) { + setupUserSessionTest(t) + now := time.Now().Unix() + createUserSessionTestUser(t, 1007, 7) + current := newTestUserSession("list-current", 1007, now-1000) + current.UserAuthVersion = 7 + rows := make([]UserSession, 0, 107) + rows = append(rows, *current) + for i := 0; i < 105; i++ { + session := newTestUserSession(fmt.Sprintf("list-other-%03d", i), 1007, now-int64(i)) + session.UserAuthVersion = 7 + rows = append(rows, *session) + } + stale := newTestUserSession("list-stale-auth-version", 1007, now+1) + stale.UserAuthVersion = 6 + rows = append(rows, *stale) + require.NoError(t, DB.CreateInBatches(rows, 100).Error) + + sessions, err := ListActiveUserSessions(1007, current.SID, now) + require.NoError(t, err) + require.Len(t, sessions, 100) + assert.Equal(t, current.SID, sessions[0].SID) + for _, session := range sessions { + assert.Equal(t, int64(7), session.UserAuthVersion) + assert.NotEqual(t, stale.SID, session.SID) + } + + sessionsWithoutCurrent, err := ListActiveUserSessions(1007, "missing-current", now) + require.NoError(t, err) + assert.Len(t, sessionsWithoutCurrent, userSessionListLimit, "a missing current SID must not reduce the total list limit") +} + +func TestRevokeUserSessionsReturnsCumulativeProgressAndSupportsRetry(t *testing.T) { + setupUserSessionTest(t) + now := time.Now().Unix() + createUserSessionTestUser(t, 1008, 1) + rows := make([]UserSession, 0, userSessionRevokeBatchSize+1) + for i := 0; i < userSessionRevokeBatchSize+1; i++ { + rows = append(rows, *newTestUserSession(fmt.Sprintf("batch-revoke-%03d", i), 1008, now)) + } + require.NoError(t, DB.CreateInBatches(rows, 100).Error) + + forcedErr := errors.New("forced second revoke batch failure") + callbackName := "test:fail_second_user_session_revoke_batch" + updateCalls := 0 + callbackRegistered := true + require.NoError(t, DB.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement != nil && tx.Statement.Table == "user_sessions" { + updateCalls++ + if updateCalls == 2 { + tx.AddError(forcedErr) + } + } + })) + t.Cleanup(func() { + if callbackRegistered { + _ = DB.Callback().Update().Remove(callbackName) + } + }) + + affected, err := RevokeAllUserSessions(1008, "batch-test") + assert.ErrorIs(t, err, forcedErr) + assert.Equal(t, int64(userSessionRevokeBatchSize), affected) + require.NoError(t, DB.Callback().Update().Remove(callbackName)) + callbackRegistered = false + + retried, err := RevokeAllUserSessions(1008, "batch-test-retry") + require.NoError(t, err) + assert.Equal(t, int64(1), retried) + var activeCount int64 + require.NoError(t, DB.Model(&UserSession{}).Where("user_id = ? AND status = ?", 1008, UserSessionStatusActive).Count(&activeCount).Error) + assert.Zero(t, activeCount) +} + +func TestDeleteExpiredUserSessionsLoopsInChunksAndRechecksPredicate(t *testing.T) { + setupUserSessionTest(t) + now := time.Now().Unix() + common.UserSessionRevokedRetentionDays = 7 + common.UserSessionIssuanceWindowSeconds = 3600 + oldCreatedAt := now - 7200 + rows := make([]UserSession, 0, userSessionCleanupScanLimit+5) + race := newTestUserSession("cleanup-race", 1009, now-1000) + race.CreatedAt = oldCreatedAt + race.ExpiresAt = now - 1000 + rows = append(rows, *race) + for i := 0; i < userSessionCleanupScanLimit+1; i++ { + session := newTestUserSession(fmt.Sprintf("cleanup-expired-%04d", i), 1009, now-100) + session.CreatedAt = oldCreatedAt - int64(i) + session.ExpiresAt = now - 100 + rows = append(rows, *session) + } + oldRevoked := newTestUserSession("cleanup-old-revoked", 1009, now-10) + oldRevoked.CreatedAt = oldCreatedAt + oldRevoked.Status = UserSessionStatusRevoked + oldRevoked.RevokedAt = now - int64(8*24*time.Hour/time.Second) + rows = append(rows, *oldRevoked) + recentRevoked := newTestUserSession("cleanup-recent-revoked", 1009, now-9) + recentRevoked.CreatedAt = oldCreatedAt + recentRevoked.Status = UserSessionStatusRevoked + recentRevoked.RevokedAt = now - int64(6*24*time.Hour/time.Second) + recentRevoked.ExpiresAt = now - 100 + rows = append(rows, *recentRevoked) + recentIssuedExpired := newTestUserSession("cleanup-recent-issued-expired", 1009, now-1800) + recentIssuedExpired.ExpiresAt = now - 100 + rows = append(rows, *recentIssuedExpired) + expiryBoundary := newTestUserSession("cleanup-expiry-boundary", 1009, now-7) + expiryBoundary.CreatedAt = oldCreatedAt + expiryBoundary.ExpiresAt = now + rows = append(rows, *expiryBoundary) + revokedBoundary := newTestUserSession("cleanup-revoked-boundary", 1009, now-6) + revokedBoundary.CreatedAt = oldCreatedAt + revokedBoundary.Status = UserSessionStatusRevoked + revokedBoundary.RevokedAt = now - int64(7*24*time.Hour/time.Second) + rows = append(rows, *revokedBoundary) + live := newTestUserSession("cleanup-live", 1009, now-8) + rows = append(rows, *live) + require.NoError(t, DB.CreateInBatches(rows, 100).Error) + + callbackName := "test:recheck_user_session_cleanup_predicate" + deleteCalls := 0 + mutated := false + require.NoError(t, DB.Callback().Delete().Before("gorm:delete").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement == nil || tx.Statement.Table != "user_sessions" { + return + } + deleteCalls++ + if !mutated { + mutated = true + tx.Exec("UPDATE user_sessions SET expires_at = ? WHERE sid = ?", now+3600, race.SID) + } + })) + t.Cleanup(func() { _ = DB.Callback().Delete().Remove(callbackName) }) + + require.NoError(t, DeleteExpiredUserSessions(now)) + require.NoError(t, DeleteOldRevokedUserSessions(now)) + assert.Equal(t, 4, deleteCalls, "expired and retained-revoked scans each delete in bounded chunks") + var remaining []UserSession + require.NoError(t, DB.Order("sid").Find(&remaining).Error) + require.Len(t, remaining, 6) + remainingSIDs := make([]string, 0, len(remaining)) + for _, session := range remaining { + remainingSIDs = append(remainingSIDs, session.SID) + } + assert.ElementsMatch(t, []string{ + race.SID, + recentRevoked.SID, + recentIssuedExpired.SID, + expiryBoundary.SID, + revokedBoundary.SID, + live.SID, + }, remainingSIDs) +} + +func TestUserSessionGrowthQueryIndexesExist(t *testing.T) { + setupUserSessionTest(t) + migrator := DB.Migrator() + assert.True(t, migrator.HasIndex(&UserSession{}, "idx_user_sessions_expires_at")) + assert.True(t, migrator.HasIndex(&UserSession{}, "idx_user_sessions_user_created")) + assert.True(t, migrator.HasIndex(&UserSession{}, "idx_user_sessions_status_revoked")) +} + func TestUserBaseIncludesAuthorizationFields(t *testing.T) { user := User{ Id: 42, diff --git a/service/auth_cleanup.go b/service/auth_cleanup.go index f4d4ff665159..10422754000c 100644 --- a/service/auth_cleanup.go +++ b/service/auth_cleanup.go @@ -1,6 +1,7 @@ package service import ( + "fmt" "time" "github.com/QuantumNous/new-api/common" @@ -27,9 +28,23 @@ func StartAuthArtifactCleanup() { func cleanupAuthArtifacts() { now := time.Now() + count, err := model.CountUserSessionsCreatedSince(0, now.Add(-time.Hour).Unix()) + if err != nil { + common.SysError("failed to count hourly user session issuance: " + err.Error()) + } else if count > int64(common.UserSessionHourlyAlertThreshold) { + common.SysError(fmt.Sprintf( + "hourly user session issuance exceeded alert threshold: count=%d threshold=%d window_seconds=%d", + count, + common.UserSessionHourlyAlertThreshold, + int64(time.Hour/time.Second), + )) + } if err := model.DeleteExpiredUserSessions(now.Unix()); err != nil { common.SysError("failed to delete expired user sessions: " + err.Error()) } + if err := model.DeleteOldRevokedUserSessions(now.Unix()); err != nil { + common.SysError("failed to delete old revoked user sessions: " + err.Error()) + } if err := model.DeleteExpiredAuthFlows(now); err != nil { common.SysError("failed to delete expired authentication flows: " + err.Error()) } diff --git a/service/auth_session.go b/service/auth_session.go index 8a28be711998..ffe0cb731ab0 100644 --- a/service/auth_session.go +++ b/service/auth_session.go @@ -64,11 +64,25 @@ func createLoginSession(userID int, expectedAuthVersion int64, loginMethod, ip, if expectedAuthVersion > 0 && user.AuthVersion != expectedAuthVersion { return nil, ErrLoginSessionRevoked } + now := time.Now().Unix() + activeCount, err := model.CountActiveUserSessions(userID, now) + if err != nil { + return nil, err + } + if activeCount >= int64(common.UserSessionActiveLimit) { + return nil, model.ErrUserSessionLimit + } + issuanceCount, err := model.CountUserSessionsCreatedSince(userID, now-common.UserSessionIssuanceWindowSeconds) + if err != nil { + return nil, err + } + if issuanceCount >= int64(common.UserSessionIssuanceLimit) { + return nil, model.ErrUserSessionIssuanceLimit + } refreshSecret, err := common.GenerateRandomCharsKey(64) if err != nil { return nil, err } - now := time.Now().Unix() session := &model.UserSession{ SID: uuid.NewString(), UserID: userID, @@ -264,7 +278,7 @@ func RefreshTokenSID(rawRefreshToken string) (string, bool) { } func ListLoginSessions(userID int, currentSID string) ([]LoginSessionView, error) { - sessions, err := model.ListActiveUserSessions(userID, time.Now().Unix()) + sessions, err := model.ListActiveUserSessions(userID, currentSID, time.Now().Unix()) if err != nil { return nil, err } @@ -373,6 +387,10 @@ func truncateAuthMetadata(value string, max int) string { func authSessionErrorCode(err error) (int, string) { switch { + case errors.Is(err, model.ErrUserSessionLimit): + return http.StatusConflict, "AUTH_SESSION_LIMIT" + case errors.Is(err, model.ErrUserSessionIssuanceLimit): + return http.StatusTooManyRequests, "AUTH_SESSION_ISSUANCE_LIMIT" case errors.Is(err, ErrLoginSessionMismatch): return http.StatusConflict, "AUTH_SESSION_MISMATCH" case errors.Is(err, ErrRefreshRace): diff --git a/service/auth_session_test.go b/service/auth_session_test.go index 3c2a9a34216d..5342ff4fcd83 100644 --- a/service/auth_session_test.go +++ b/service/auth_session_test.go @@ -1,13 +1,19 @@ package service import ( + "bytes" "errors" + "fmt" + "strings" "testing" "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" + "github.com/go-redis/redis/v8" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -16,6 +22,11 @@ import ( func setupAuthSessionTestDB(t *testing.T) *model.User { t.Helper() previousDB, previousRedis := model.DB, common.RedisEnabled + previousActiveLimit := common.UserSessionActiveLimit + previousIssuanceLimit := common.UserSessionIssuanceLimit + previousIssuanceWindow := common.UserSessionIssuanceWindowSeconds + previousRevokedRetention := common.UserSessionRevokedRetentionDays + previousAlertThreshold := common.UserSessionHourlyAlertThreshold db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) sqlDB, err := db.DB() @@ -24,9 +35,19 @@ func setupAuthSessionTestDB(t *testing.T) *model.User { require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.AuthFlow{})) model.DB = db common.RedisEnabled = false + common.UserSessionActiveLimit = common.DefaultUserSessionActiveLimit + common.UserSessionIssuanceLimit = common.DefaultUserSessionIssuanceLimit + common.UserSessionIssuanceWindowSeconds = int64(common.DefaultUserSessionIssuanceWindowSeconds) + common.UserSessionRevokedRetentionDays = common.DefaultUserSessionRevokedRetentionDays + common.UserSessionHourlyAlertThreshold = common.DefaultUserSessionHourlyAlertThreshold t.Cleanup(func() { model.DB = previousDB common.RedisEnabled = previousRedis + common.UserSessionActiveLimit = previousActiveLimit + common.UserSessionIssuanceLimit = previousIssuanceLimit + common.UserSessionIssuanceWindowSeconds = previousIssuanceWindow + common.UserSessionRevokedRetentionDays = previousRevokedRetention + common.UserSessionHourlyAlertThreshold = previousAlertThreshold _ = sqlDB.Close() }) user := &model.User{ @@ -41,6 +62,202 @@ func setupAuthSessionTestDB(t *testing.T) *model.User { return user } +func useIndependentAuthSessionRedis(t *testing.T) (*miniredis.Miniredis, *redis.Client, *miniredis.Miniredis, *redis.Client) { + t.Helper() + previousRedisEnabled := common.RedisEnabled + previousRDB := common.RDB + previousSyncFrequency := common.SyncFrequency + serverA := miniredis.RunT(t) + serverB := miniredis.RunT(t) + clientA := redis.NewClient(&redis.Options{Addr: serverA.Addr()}) + clientB := redis.NewClient(&redis.Options{Addr: serverB.Addr()}) + common.RedisEnabled = true + common.SyncFrequency = 2 + common.RDB = clientA + t.Cleanup(func() { + _ = clientA.Close() + _ = clientB.Close() + common.RedisEnabled = previousRedisEnabled + common.RDB = previousRDB + common.SyncFrequency = previousSyncFrequency + }) + return serverA, clientA, serverB, clientB +} + +func cachedLoginSessionKey(t *testing.T, server *miniredis.Miniredis) string { + t.Helper() + for _, key := range server.Keys() { + if strings.HasPrefix(key, "auth:session:") { + return key + } + } + require.FailNow(t, "login session was not cached") + return "" +} + +func TestCreateLoginSessionEnforcesActiveLimitAcrossAuthVersions(t *testing.T) { + useTestSessionSecret(t) + user := setupAuthSessionTestDB(t) + common.UserSessionActiveLimit = 50 + common.UserSessionIssuanceLimit = 100 + now := time.Now().Unix() + rows := make([]model.UserSession, 0, 49) + for i := 0; i < 49; i++ { + authVersion := user.AuthVersion + if i == 0 { + authVersion++ + } + rows = append(rows, model.UserSession{ + SID: fmt.Sprintf("active-limit-%02d", i), + UserID: user.Id, + Version: 1, + UserAuthVersion: authVersion, + Status: model.UserSessionStatusActive, + RefreshHash: fmt.Sprintf("hash-%02d", i), + LoginMethod: "password", + CreatedAt: now - int64(i), + LastActiveAt: now - int64(i), + ExpiresAt: now + 3600, + }) + } + require.NoError(t, model.DB.Create(&rows).Error) + + _, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent") + require.NoError(t, err, "49 active sessions must allow creation of the 50th") + + _, err = CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent") + assert.ErrorIs(t, err, model.ErrUserSessionLimit) + var count int64 + require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error) + assert.Equal(t, int64(50), count) +} + +func TestCreateLoginSessionEnforcesIssuanceLimitAcrossAllStatuses(t *testing.T) { + useTestSessionSecret(t) + user := setupAuthSessionTestDB(t) + common.UserSessionActiveLimit = 10 + common.UserSessionIssuanceLimit = 3 + common.UserSessionIssuanceWindowSeconds = 60 + now := time.Now().Unix() + rows := []model.UserSession{ + { + SID: "issuance-limit-revoked", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion + 1, + Status: model.UserSessionStatusRevoked, RefreshHash: "hash-revoked", LoginMethod: "password", + CreatedAt: now - 2, LastActiveAt: now - 2, ExpiresAt: now + 3600, RevokedAt: now - 1, + }, + { + SID: "issuance-limit-expired", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion, + Status: model.UserSessionStatusActive, RefreshHash: "hash-expired", LoginMethod: "password", + CreatedAt: now - 1, LastActiveAt: now - 1, ExpiresAt: now - 1, + }, + { + SID: "issuance-outside-effective-window", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion, + Status: model.UserSessionStatusRevoked, RefreshHash: "hash-outside", LoginMethod: "password", + CreatedAt: now - 61, LastActiveAt: now - 61, ExpiresAt: now + 3600, RevokedAt: now - 60, + }, + } + require.NoError(t, model.DB.Create(&rows).Error) + + _, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent") + require.NoError(t, err, "rows outside the effective issuance window must not consume the limit") + + _, err = CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent") + assert.ErrorIs(t, err, model.ErrUserSessionIssuanceLimit) + var count int64 + require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error) + assert.Equal(t, int64(4), count) +} + +func TestPasswordResetDoesNotClearSessionIssuanceHistory(t *testing.T) { + useTestSessionSecret(t) + user := setupAuthSessionTestDB(t) + common.UserSessionActiveLimit = 50 + common.UserSessionIssuanceLimit = 1 + email := "session-reset@example.com" + require.NoError(t, model.DB.Model(user).Update("email", email).Error) + + _, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent") + require.NoError(t, err) + require.NoError(t, model.ResetUserPasswordByEmail(email, "new-password")) + + _, err = CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent") + assert.ErrorIs(t, err, model.ErrUserSessionIssuanceLimit) +} + +func TestCreateLoginSessionFailsClosedWhenLimitCountFails(t *testing.T) { + useTestSessionSecret(t) + user := setupAuthSessionTestDB(t) + forcedErr := errors.New("forced session count failure") + callbackName := "test:fail_user_session_limit_count" + callbackRegistered := true + require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement != nil && tx.Statement.Table == "user_sessions" { + tx.AddError(forcedErr) + } + })) + t.Cleanup(func() { + if callbackRegistered { + _ = model.DB.Callback().Query().Remove(callbackName) + } + }) + + _, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent") + assert.ErrorIs(t, err, forcedErr) + require.NoError(t, model.DB.Callback().Query().Remove(callbackName)) + callbackRegistered = false + var count int64 + require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error) + assert.Zero(t, count) +} + +func TestCleanupAuthArtifactsAlertsBeforeDeletingHourlyIssuance(t *testing.T) { + setupAuthSessionTestDB(t) + common.UserSessionHourlyAlertThreshold = 2 + common.UserSessionIssuanceWindowSeconds = 1 + now := time.Now() + boundaryRows := make([]model.UserSession, 0, 2) + for i := 0; i < 2; i++ { + boundaryRows = append(boundaryRows, model.UserSession{ + SID: "hourly-boundary-" + string(rune('a'+i)), UserID: 1, Version: 1, UserAuthVersion: 1, + Status: model.UserSessionStatusActive, RefreshHash: "hash", LoginMethod: "password", + CreatedAt: now.Add(-2 * time.Second).Unix(), LastActiveAt: now.Add(-time.Hour).Unix(), ExpiresAt: now.Add(-time.Minute).Unix(), + }) + } + require.NoError(t, model.DB.Create(&boundaryRows).Error) + + var logBuffer bytes.Buffer + common.LogWriterMu.Lock() + previousErrorWriter := gin.DefaultErrorWriter + gin.DefaultErrorWriter = &logBuffer + common.LogWriterMu.Unlock() + t.Cleanup(func() { + common.LogWriterMu.Lock() + gin.DefaultErrorWriter = previousErrorWriter + common.LogWriterMu.Unlock() + }) + + cleanupAuthArtifacts() + assert.Empty(t, logBuffer.String(), "the hourly alert uses a strict greater-than threshold") + var count int64 + require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error) + assert.Zero(t, count) + + exceededRows := make([]model.UserSession, 0, 3) + for i := 0; i < 3; i++ { + exceededRows = append(exceededRows, model.UserSession{ + SID: "hourly-exceeded-" + string(rune('a'+i)), UserID: 1, Version: 1, UserAuthVersion: 1, + Status: model.UserSessionStatusActive, RefreshHash: "hash", LoginMethod: "password", + CreatedAt: now.Add(-2 * time.Second).Unix(), LastActiveAt: now.Add(-time.Hour).Unix(), ExpiresAt: now.Add(-time.Minute).Unix(), + }) + } + require.NoError(t, model.DB.Create(&exceededRows).Error) + logBuffer.Reset() + cleanupAuthArtifacts() + assert.Contains(t, logBuffer.String(), "hourly user session issuance exceeded alert threshold") + require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error) + assert.Zero(t, count, "alerting must happen before expired rows are deleted") +} + func TestCleanupAuthArtifactsRemovesOnlyExpiredRecords(t *testing.T) { setupAuthSessionTestDB(t) now := time.Now() @@ -48,7 +265,7 @@ func TestCleanupAuthArtifactsRemovesOnlyExpiredRecords(t *testing.T) { require.NoError(t, model.DB.Create(&model.UserSession{ SID: "expired-session", UserID: 1, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "hash", LoginMethod: "password", - LastActiveAt: oldExpiry.Unix(), ExpiresAt: oldExpiry.Unix(), + CreatedAt: oldExpiry.Unix(), LastActiveAt: oldExpiry.Unix(), ExpiresAt: oldExpiry.Unix(), }).Error) require.NoError(t, model.DB.Create(&model.AuthFlow{ TokenHash: "expired-flow", Purpose: model.AuthFlowPurposeTwoFALogin, @@ -70,6 +287,43 @@ func TestCleanupAuthArtifactsRemovesOnlyExpiredRecords(t *testing.T) { assert.Equal(t, "recent-flow", flows[0].TokenHash) } +func TestCleanupAuthArtifactsContinuesWithRevokedCleanupAfterExpiredBatchFailure(t *testing.T) { + setupAuthSessionTestDB(t) + now := time.Now() + oldCreatedAt := now.Add(-8 * 24 * time.Hour).Unix() + require.NoError(t, model.DB.Create(&[]model.UserSession{ + { + SID: "failed-expired-cleanup", UserID: 1, Version: 1, UserAuthVersion: 1, + Status: model.UserSessionStatusActive, RefreshHash: "hash-expired", LoginMethod: "password", + CreatedAt: oldCreatedAt, LastActiveAt: oldCreatedAt, ExpiresAt: now.Add(-time.Minute).Unix(), + }, + { + SID: "independent-revoked-cleanup", UserID: 1, Version: 1, UserAuthVersion: 1, + Status: model.UserSessionStatusRevoked, RefreshHash: "hash-revoked", LoginMethod: "password", + CreatedAt: oldCreatedAt, LastActiveAt: oldCreatedAt, ExpiresAt: now.Add(time.Hour).Unix(), + RevokedAt: now.Add(-8 * 24 * time.Hour).Unix(), + }, + }).Error) + + forcedErr := errors.New("forced expired cleanup failure") + callbackName := "test:fail_first_user_session_cleanup_batch" + failedFirstDelete := false + require.NoError(t, model.DB.Callback().Delete().Before("gorm:delete").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement != nil && tx.Statement.Table == "user_sessions" && !failedFirstDelete { + failedFirstDelete = true + tx.AddError(forcedErr) + } + })) + t.Cleanup(func() { _ = model.DB.Callback().Delete().Remove(callbackName) }) + + cleanupAuthArtifacts() + + var expired model.UserSession + require.NoError(t, model.DB.First(&expired, "sid = ?", "failed-expired-cleanup").Error) + var revoked model.UserSession + assert.ErrorIs(t, model.DB.First(&revoked, "sid = ?", "independent-revoked-cleanup").Error, gorm.ErrRecordNotFound) +} + func TestLoginSessionCreateRefreshAndRevoke(t *testing.T) { useTestSessionSecret(t) user := setupAuthSessionTestDB(t) @@ -101,6 +355,65 @@ func TestLoginSessionCreateRefreshAndRevoke(t *testing.T) { assert.True(t, errors.Is(err, ErrLoginSessionRevoked)) } +func TestIndependentRedisSessionRevokeConvergesAfterCacheTTL(t *testing.T) { + useTestSessionSecret(t) + user := setupAuthSessionTestDB(t) + _, clientA, serverB, clientB := useIndependentAuthSessionRedis(t) + + common.RDB = clientA + bundle, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "node-a") + require.NoError(t, err) + identity, err := ParseAccessToken(bundle.AccessToken) + require.NoError(t, err) + + common.RDB = clientB + _, _, err = ValidateLoginSession(identity) + require.NoError(t, err) + assert.NotEmpty(t, cachedLoginSessionKey(t, serverB), "node B must hold its own session cache entry") + + common.RDB = clientA + require.NoError(t, RevokeByRefreshToken(bundle.RefreshToken, bundle.Session.SID, "logout")) + + serverB.FastForward(3 * time.Second) + common.RDB = clientB + _, _, err = ValidateLoginSession(identity) + assert.ErrorIs(t, err, ErrLoginSessionRevoked) +} + +func TestIndependentRedisAuthVersionAdvanceConvergesAfterCacheTTL(t *testing.T) { + useTestSessionSecret(t) + user := setupAuthSessionTestDB(t) + _, clientA, serverB, clientB := useIndependentAuthSessionRedis(t) + + common.RDB = clientA + bundle, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "node-a") + require.NoError(t, err) + oldIdentity, err := ParseAccessToken(bundle.AccessToken) + require.NoError(t, err) + + common.RDB = clientB + _, _, err = ValidateLoginSession(oldIdentity) + require.NoError(t, err) + cacheKey := cachedLoginSessionKey(t, serverB) + version := serverB.HGet(cacheKey, "Version") + assert.Equal(t, "1", version, "node B must hold the pre-rotation session version") + + common.RDB = clientA + rotated, err := AdvanceCurrentSessionSecurity(oldIdentity, "security_update") + require.NoError(t, err) + newIdentity, err := ParseAccessToken(rotated.AccessToken) + require.NoError(t, err) + assert.Greater(t, newIdentity.SessionVersion, oldIdentity.SessionVersion) + assert.Greater(t, newIdentity.UserAuthVersion, oldIdentity.UserAuthVersion) + + serverB.FastForward(3 * time.Second) + common.RDB = clientB + _, _, err = ValidateLoginSession(newIdentity) + require.NoError(t, err) + _, _, err = ValidateLoginSession(oldIdentity) + assert.ErrorIs(t, err, ErrLoginSessionRevoked) +} + func TestUserAuthVersionInvalidatesExistingSession(t *testing.T) { useTestSessionSecret(t) user := setupAuthSessionTestDB(t) diff --git a/trusted_proxies.go b/trusted_proxies.go new file mode 100644 index 000000000000..6aff2ba51a41 --- /dev/null +++ b/trusted_proxies.go @@ -0,0 +1,35 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/gin-gonic/gin" +) + +func configureTrustedProxies(engine *gin.Engine) error { + rawTrustedProxies := os.Getenv("TRUSTED_PROXIES") + if strings.TrimSpace(rawTrustedProxies) == "" { + // Gin trusts all proxies by default. An explicit nil default prevents a + // direct client from spoofing X-Forwarded-For to evade IP rate limits. + return engine.SetTrustedProxies(nil) + } + + parts := strings.Split(rawTrustedProxies, ",") + trustedProxies := make([]string, 0, len(parts)) + for _, part := range parts { + trustedProxy := strings.TrimSpace(part) + if trustedProxy != "" { + trustedProxies = append(trustedProxies, trustedProxy) + } + } + if len(trustedProxies) == 0 { + return errors.New("TRUSTED_PROXIES does not contain an IP address or CIDR") + } + if err := engine.SetTrustedProxies(trustedProxies); err != nil { + return fmt.Errorf("invalid TRUSTED_PROXIES: %w", err) + } + return nil +} diff --git a/trusted_proxies_test.go b/trusted_proxies_test.go new file mode 100644 index 000000000000..db3c9bca1fdb --- /dev/null +++ b/trusted_proxies_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func requestClientIP(router http.Handler, remoteAddr string, forwardedFor string) string { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/client-ip", nil) + request.RemoteAddr = remoteAddr + if forwardedFor != "" { + request.Header.Set("X-Forwarded-For", forwardedFor) + } + router.ServeHTTP(recorder, request) + return recorder.Body.String() +} + +func newClientIPRouter() *gin.Engine { + router := gin.New() + router.GET("/client-ip", func(c *gin.Context) { + c.String(http.StatusOK, c.ClientIP()) + }) + return router +} + +func TestConfigureTrustedProxiesDefaultsToNoTrust(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("TRUSTED_PROXIES", "") + router := newClientIPRouter() + require.NoError(t, configureTrustedProxies(router)) + + clientIP := requestClientIP(router, "198.51.100.10:12345", "203.0.113.10") + assert.Equal(t, "198.51.100.10", clientIP, "an unconfigured proxy must not make a spoofed X-Forwarded-For authoritative") +} + +func TestConfigureTrustedProxiesAcceptsTrimmedIPsAndCIDRs(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("TRUSTED_PROXIES", " 192.0.2.0/24, 127.0.0.1 ") + router := newClientIPRouter() + require.NoError(t, configureTrustedProxies(router)) + + trustedClientIP := requestClientIP(router, "192.0.2.10:12345", "203.0.113.20") + assert.Equal(t, "203.0.113.20", trustedClientIP) + + untrustedClientIP := requestClientIP(router, "198.51.100.20:12345", "203.0.113.21") + assert.Equal(t, "198.51.100.20", untrustedClientIP) +} + +func TestConfigureTrustedProxiesRejectsInvalidConfiguration(t *testing.T) { + gin.SetMode(gin.TestMode) + testCases := []struct { + name string + value string + }{ + {name: "no entries", value: ", ,"}, + {name: "invalid entry", value: "not-an-ip"}, + {name: "mixed valid and invalid entries", value: "127.0.0.1, not-an-ip"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Setenv("TRUSTED_PROXIES", testCase.value) + router := newClientIPRouter() + assert.Error(t, configureTrustedProxies(router)) + }) + } +} diff --git a/web/default/src/features/auth/otp/components/otp-form.tsx b/web/default/src/features/auth/otp/components/otp-form.tsx index dc2494dd2e21..4dadf685952f 100644 --- a/web/default/src/features/auth/otp/components/otp-form.tsx +++ b/web/default/src/features/auth/otp/components/otp-form.tsx @@ -54,6 +54,7 @@ import { formatBackupCode, cleanBackupCode, } from '@/features/auth/lib/validation' +import { getServerErrorMessageKey } from '@/lib/server-error-message' import { cn } from '@/lib/utils' import { useAuthStore } from '@/stores/auth-store' @@ -105,6 +106,7 @@ export function OtpForm({ className, ...props }: OtpFormProps) { }) if (!res.success) { + if (getServerErrorMessageKey(res)) return toast.error(res.message || t('Invalid code')) return } @@ -118,6 +120,7 @@ export function OtpForm({ className, ...props }: OtpFormProps) { } catch (error) { // eslint-disable-next-line no-console console.error('2FA verification error:', error) + if (getServerErrorMessageKey(error)) return const errorMessage = error instanceof Error ? error.message : t('Verification failed') toast.error(errorMessage) diff --git a/web/default/src/features/auth/sign-in/components/user-auth-form.tsx b/web/default/src/features/auth/sign-in/components/user-auth-form.tsx index 2782b5cf53e0..65fa02f02023 100644 --- a/web/default/src/features/auth/sign-in/components/user-auth-form.tsx +++ b/web/default/src/features/auth/sign-in/components/user-auth-form.tsx @@ -54,6 +54,7 @@ import { prepareCredentialRequestOptions, isPasskeySupported as detectPasskeySupport, } from '@/lib/passkey' +import { getServerErrorMessageKey } from '@/lib/server-error-message' import { cn } from '@/lib/utils' import { useAuthStore } from '@/stores/auth-store' @@ -218,9 +219,11 @@ export function UserAuthForm({ toast.success(t('Signed in via WeChat')) handleWeChatDialogChange(false) } else { + if (getServerErrorMessageKey(res)) return toast.error(res?.message || loginFailedMessage) } - } catch { + } catch (error: unknown) { + if (getServerErrorMessageKey(error)) return toast.error(loginFailedMessage) } finally { setIsWeChatSubmitting(false) @@ -247,6 +250,7 @@ export function UserAuthForm({ try { const begin = await beginPasskeyLogin() if (!begin.success) { + if (getServerErrorMessageKey(begin)) return throw new Error(begin.message || t('Failed to start Passkey login')) } @@ -274,6 +278,7 @@ export function UserAuthForm({ const finish = await finishPasskeyLogin(flowToken, assertion) if (!finish.success) { + if (getServerErrorMessageKey(finish)) return throw new Error(finish.message || t('Failed to complete Passkey login')) } @@ -284,6 +289,7 @@ export function UserAuthForm({ await handleLoginSuccess(finish.data, redirectTo) toast.success(t('Signed in with Passkey')) } catch (error: unknown) { + if (getServerErrorMessageKey(error)) return if (error instanceof DOMException && error.name === 'NotAllowedError') { toast.info(t('Passkey login was cancelled or timed out')) } else if (error instanceof Error) { diff --git a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx index ec3e9517eacb..56e2d004fc76 100644 --- a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx @@ -51,6 +51,7 @@ import { } from '@/features/auth/lib/storage' import { useStatus } from '@/hooks/use-status' import { isAuthBundle } from '@/lib/api' +import { getServerErrorMessageKey } from '@/lib/server-error-message' import { cn } from '@/lib/utils' export function SignUpForm({ @@ -219,9 +220,11 @@ export function SignUpForm({ toast.success(t('Signed in via WeChat')) handleWeChatDialogChange(false) } else { + if (getServerErrorMessageKey(res)) return toast.error(res?.message || t('Login failed')) } - } catch { + } catch (error: unknown) { + if (getServerErrorMessageKey(error)) return toast.error(t('Login failed')) } finally { setIsWeChatSubmitting(false) diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index c0796abc4b4f..bfd1db2c592b 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -4659,7 +4659,9 @@ "Tokens per minute": "Tokens per minute", "Tokens since launch": "Tokens since launch", "Tokens-only mode will show raw quota values regardless of this toggle.": "Tokens-only mode will show raw quota values regardless of this toggle.", + "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.", "Too many files. Some were not added.": "Too many files. Some were not added.", + "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.", "Too many requests": "Too many requests", "Tool / function declarations the model may call": "Tool / function declarations the model may call", "Tool identifier": "Tool identifier", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 585dd13d71a7..bc49d2b1869c 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -4659,7 +4659,9 @@ "Tokens per minute": "Jetons par minute", "Tokens since launch": "Jetons depuis le lancement", "Tokens-only mode will show raw quota values regardless of this toggle.": "Le mode Tokens uniquement affichera les valeurs de quota brutes indépendamment de ce basculement.", + "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "Le nombre maximal de sessions de connexion actives est atteint. Sur un appareil déjà connecté, ouvrez « Sessions de connexion » et utilisez « Déconnecter les autres sessions » pour les révoquer. Si vous n’avez accès à aucun appareil connecté, réinitialisez votre mot de passe pour fermer toutes les sessions.", "Too many files. Some were not added.": "Trop de fichiers. Certains n'ont pas été ajoutés.", + "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "Trop de sessions de connexion ont été créées récemment. Attendez la fin de la fenêtre glissante, puis réessayez.", "Too many requests": "Trop de requêtes", "Tool / function declarations the model may call": "Déclarations d'outils / fonctions que le modèle peut appeler", "Tool identifier": "Identifiant d’outil", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index bd564e954186..a8976785e72b 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -4659,7 +4659,9 @@ "Tokens per minute": "1分あたりのトークン", "Tokens since launch": "リリース以降のトークン", "Tokens-only mode will show raw quota values regardless of this toggle.": "トークンのみモードでは、このトグルに関係なく生のクォータ値が表示されます。", + "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "有効なログインセッション数が上限に達しました。すでにログイン済みの端末で「ログインセッション」を開き、「他のセッションからログアウト」を使用して取り消してください。ログイン済み端末を利用できない場合は、パスワードをリセットしてすべてのセッションからログアウトしてください。", "Too many files. Some were not added.": "ファイルが多すぎます。一部は追加されませんでした。", + "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "最近作成されたログインセッションが多すぎます。ローリングウィンドウが経過してから、もう一度お試しください。", "Too many requests": "リクエストが多すぎます", "Tool / function declarations the model may call": "モデルが呼び出せるツール / 関数の宣言", "Tool identifier": "ツールID", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 0fde4d335d19..b0c7a554a802 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -4659,7 +4659,9 @@ "Tokens per minute": "Токенов в минуту", "Tokens since launch": "Токенов с запуска", "Tokens-only mode will show raw quota values regardless of this toggle.": "Режим «только токены» будет показывать необработанные значения квот независимо от этого переключателя.", + "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "Достигнут лимит активных сеансов входа. На устройстве, где вы уже вошли, откройте «Сеансы входа» и выберите «Завершить другие сеансы», чтобы отозвать их. Если доступа к такому устройству нет, сбросьте пароль, чтобы завершить все сеансы.", "Too many files. Some were not added.": "Слишком много файлов. Некоторые не были добавлены.", + "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "За последнее время создано слишком много сеансов входа. Дождитесь окончания скользящего временного окна и повторите попытку.", "Too many requests": "Слишком много запросов", "Tool / function declarations the model may call": "Объявления инструментов и функций, которые модель может вызывать", "Tool identifier": "Идентификатор инструмента", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 277921fd5d4c..a58bcc7b761b 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -4659,7 +4659,9 @@ "Tokens per minute": "Số token mỗi phút", "Tokens since launch": "Token kể từ khi ra mắt", "Tokens-only mode will show raw quota values regardless of this toggle.": "Chế độ Tokens-only sẽ hiển thị giá trị quota thô bất kể tùy chọn này.", + "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "Đã đạt giới hạn phiên đăng nhập đang hoạt động. Trên một thiết bị đã đăng nhập, hãy mở “Phiên đăng nhập” và dùng “Đăng xuất các phiên khác” để thu hồi chúng. Nếu bạn không thể truy cập thiết bị nào đã đăng nhập, hãy đặt lại mật khẩu để đăng xuất khỏi tất cả phiên.", "Too many files. Some were not added.": "Quá nhiều tệp. Một số không được thêm.", + "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "Gần đây đã tạo quá nhiều phiên đăng nhập. Vui lòng chờ cửa sổ thời gian trượt kết thúc rồi thử lại.", "Too many requests": "Quá nhiều yêu cầu", "Tool / function declarations the model may call": "Khai báo công cụ / hàm mà model có thể gọi", "Tool identifier": "Định danh công cụ", diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index 10a83fb0ef36..f563d6e72446 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -4659,7 +4659,9 @@ "Tokens per minute": "每分鐘 Token 數", "Tokens since launch": "發佈以來累計 Token", "Tokens-only mode will show raw quota values regardless of this toggle.": "Tokens-only 模式將無視此開關顯示原始配額值。", + "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "有效登入工作階段數量已達上限。請在一台已登入的裝置上開啟「登入工作階段」,使用「登出其他工作階段」將其撤銷。如果無法存取任何已登入裝置,請重設密碼以登出所有工作階段。", "Too many files. Some were not added.": "檔案過多。部分未添加。", + "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "近期建立的登入工作階段過多。請等待滾動時間窗口結束後再試。", "Too many requests": "請求過於頻繁", "Tool / function declarations the model may call": "模型可呼叫的工具 / 函數聲明", "Tool identifier": "工具標識", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 6a1fded65e2d..b533dc7929d9 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -4659,7 +4659,9 @@ "Tokens per minute": "每分钟 Token 数", "Tokens since launch": "发布以来累计 Token", "Tokens-only mode will show raw quota values regardless of this toggle.": "Tokens-only 模式将无视此开关显示原始配额值。", + "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "活跃登录会话数量已达上限。请在一台已登录的设备上打开“登录会话”,使用“退出其他登录会话”将其撤销。如果无法访问任何已登录设备,请重置密码以退出所有会话。", "Too many files. Some were not added.": "文件过多。部分未添加。", + "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "近期创建的登录会话过多。请等待滚动时间窗口结束后再试。", "Too many requests": "请求过于频繁", "Tool / function declarations the model may call": "模型可调用的工具 / 函数声明", "Tool identifier": "工具标识", diff --git a/web/default/src/i18n/static-keys.ts b/web/default/src/i18n/static-keys.ts index 78a8d77e965a..b57a2587ca9a 100644 --- a/web/default/src/i18n/static-keys.ts +++ b/web/default/src/i18n/static-keys.ts @@ -561,4 +561,6 @@ export const STATIC_I18N_KEYS = [ 'Failed to load', 'Expired at', 'Cancelled at', + 'Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.', + 'Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.', ] as const diff --git a/web/default/src/lib/handle-server-error.ts b/web/default/src/lib/handle-server-error.ts index 6fc9a5ad0f28..956bc8b5cb06 100644 --- a/web/default/src/lib/handle-server-error.ts +++ b/web/default/src/lib/handle-server-error.ts @@ -20,12 +20,20 @@ import { AxiosError } from 'axios' import i18next from 'i18next' import { toast } from 'sonner' +import { getServerErrorMessageKey } from '@/lib/server-error-message' + export function handleServerError(error: unknown) { // eslint-disable-next-line no-console console.log(error) let errMsg = i18next.t('Something went wrong!') + const messageKey = getServerErrorMessageKey(error) + if (messageKey) { + toast.error(i18next.t(messageKey)) + return + } + if ( error && typeof error === 'object' && diff --git a/web/default/src/lib/http-client.ts b/web/default/src/lib/http-client.ts index 9e3efa7c16dd..0a1269b39543 100644 --- a/web/default/src/lib/http-client.ts +++ b/web/default/src/lib/http-client.ts @@ -25,6 +25,7 @@ import { clearAuthentication, refreshAuthentication, } from '@/lib/auth-session' +import { getServerErrorMessageKey } from '@/lib/server-error-message' import { useAuthStore } from '@/stores/auth-store' declare module 'axios' { @@ -87,7 +88,12 @@ api.interceptors.response.use( typeof response.data?.success === 'boolean' && !response.data.success ) { - toast.error(response.data.message || t('Request failed')) + const messageKey = getServerErrorMessageKey(response.data) + toast.error( + messageKey + ? t(messageKey) + : response.data.message || t('Request failed') + ) } return response }, @@ -123,8 +129,12 @@ api.interceptors.response.use( toast.error(t('Session expired!')) } } else if (!skipErrorHandler) { - const message = - error?.response?.data?.message || error?.message || t('Request failed') + const messageKey = getServerErrorMessageKey(error) + const message = messageKey + ? t(messageKey) + : error?.response?.data?.message || + error?.message || + t('Request failed') toast.error(message) } throw error diff --git a/web/default/src/lib/server-error-message.test.ts b/web/default/src/lib/server-error-message.test.ts new file mode 100644 index 000000000000..ef5b2f329748 --- /dev/null +++ b/web/default/src/lib/server-error-message.test.ts @@ -0,0 +1,40 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { getServerErrorMessageKey } from './server-error-message' + +describe('server error message mapping', () => { + test('maps the active-session limit to recovery instructions', () => { + const message = getServerErrorMessageKey({ code: 'AUTH_SESSION_LIMIT' }) + + assert.match(message ?? '', /Sign out other sessions/) + assert.match(message ?? '', /reset your password/) + }) + + test('maps an Axios-shaped issuance limit to rolling-window guidance', () => { + const message = getServerErrorMessageKey({ + response: { data: { code: 'AUTH_SESSION_ISSUANCE_LIMIT' } }, + }) + + assert.match(message ?? '', /rolling window/) + assert.equal(getServerErrorMessageKey({ code: 'UNKNOWN_CODE' }), null) + }) +}) diff --git a/web/default/src/lib/server-error-message.ts b/web/default/src/lib/server-error-message.ts new file mode 100644 index 000000000000..fecbee464b7f --- /dev/null +++ b/web/default/src/lib/server-error-message.ts @@ -0,0 +1,49 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +const serverErrorMessageKeys = { + AUTH_SESSION_LIMIT: + 'Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.', + AUTH_SESSION_ISSUANCE_LIMIT: + 'Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.', +} as const + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +function serverErrorPayload(value: unknown): Record | null { + if (!isRecord(value)) return null + + const response = value.response + if (isRecord(response) && isRecord(response.data)) { + return response.data + } + return value +} + +export function getServerErrorMessageKey(value: unknown): string | null { + const payload = serverErrorPayload(value) + if (!payload || typeof payload.code !== 'string') return null + + return ( + serverErrorMessageKeys[ + payload.code as keyof typeof serverErrorMessageKeys + ] ?? null + ) +} diff --git a/web/default/src/routes/(auth)/oauth.tsx b/web/default/src/routes/(auth)/oauth.tsx index 15943db0f7b7..5f07360fd620 100644 --- a/web/default/src/routes/(auth)/oauth.tsx +++ b/web/default/src/routes/(auth)/oauth.tsx @@ -23,6 +23,7 @@ import { toast } from 'sonner' import { wechatLoginByCode } from '@/features/auth/api' import { applyAuthBundle, isAuthBundle } from '@/lib/api' +import { getServerErrorMessageKey } from '@/lib/server-error-message' function OAuthComponent() { const navigate = useNavigate() @@ -44,9 +45,16 @@ function OAuthComponent() { navigate({ to: target, replace: true }) return } + if (getServerErrorMessageKey(res)) { + navigate({ to: '/sign-in', replace: true }) + return + } + } + } catch (error: unknown) { + if (getServerErrorMessageKey(error)) { + navigate({ to: '/sign-in', replace: true }) + return } - } catch { - /* empty */ } toast.error(i18next.t('OAuth failed')) navigate({ to: '/sign-in', replace: true }) diff --git a/web/default/src/routes/oauth/$provider.tsx b/web/default/src/routes/oauth/$provider.tsx index 8f22e48a59fa..e08363732f41 100644 --- a/web/default/src/routes/oauth/$provider.tsx +++ b/web/default/src/routes/oauth/$provider.tsx @@ -35,6 +35,7 @@ import { } from '@/features/auth/constants' import { startOAuthBindResponseDeadline } from '@/features/auth/lib/oauth-bind-window' import { api, applyAuthBundle, isAuthBundle } from '@/lib/api' +import { getServerErrorMessageKey } from '@/lib/server-error-message' type OAuthRequestConfig = AxiosRequestConfig & { skipBusinessError?: boolean @@ -183,15 +184,25 @@ function OAuthCallback() { toast.success(i18next.t('Signed in successfully!')) return } - toast.error(response.data?.message || i18next.t('OAuth failed')) + const messageKey = getServerErrorMessageKey(response.data) + toast.error( + messageKey + ? i18next.t(messageKey) + : response.data?.message || i18next.t('OAuth failed') + ) } catch (error: unknown) { + const messageKey = getServerErrorMessageKey(error) const responseMessage = ( error as { response?: { data?: { message?: string } } } ).response?.data?.message - toast.error( - responseMessage || - (error instanceof Error ? error.message : i18next.t('OAuth failed')) - ) + if (!messageKey) { + toast.error( + responseMessage || + (error instanceof Error + ? error.message + : i18next.t('OAuth failed')) + ) + } } safeNavigate('/sign-in') })() From 371ad550cf8249e351a510e61ffb8f3e4495cb6a Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 20 Jul 2026 13:09:07 +0800 Subject: [PATCH 3/5] fix(proxy): preserve trusted proxy compatibility defaults --- .env.example | 7 +++-- README.en.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.md | 2 +- README.zh_CN.md | 2 +- README.zh_TW.md | 2 +- docker-compose.yml | 2 +- docs/authentication.md | 12 ++++---- trusted_proxies.go | 28 +++++++++++++++---- trusted_proxies_test.go | 62 ++++++++++++++++++++++++++++++++++++++--- 11 files changed, 98 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 977a4826b5d7..d62e114603c0 100644 --- a/.env.example +++ b/.env.example @@ -65,9 +65,10 @@ # TLS_INSECURE_SKIP_VERIFY=false # Gin 可信反向代理(逗号分隔的 IP/CIDR) -# 未配置时不信任任何代理,ClientIP 只使用直连地址。 -# 反向代理部署必须填写代理自身的 IP/CIDR,不要填客户端网段。 -# TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8 +# 未配置/留空:默认信任 127.0.0.0/8、::1、RFC1918 私网和 fc00::/7,并打印启动告警。 +# none:严格模式,不信任任何代理且必须单独使用;显式列表完全替代默认值,应填写代理自身地址。 +# TRUSTED_PROXIES=none +# TRUSTED_PROXIES=127.0.0.1,172.20.0.0/16 # Gemini 识别图片 最大图片数量 # GEMINI_VISION_MAX_IMAGE_NUM=16 diff --git a/README.en.md b/README.en.md index 63f286089ac9..e8a6a60565fb 100644 --- a/README.en.md +++ b/README.en.md @@ -309,7 +309,7 @@ docker run --name new-api -d --restart always \ | `SESSION_SECRET` | Authentication signing secret; must be identical on every node | - | | `SESSION_COOKIE_SECURE` | `false`/unset disables the refresh/logout OriginGuard for local HTTP dev proxies; `true` enables the Secure cookie and strict Origin checks | `false` | | `SESSION_COOKIE_TRUSTED_URL` | Required with Secure mode: comma-separated exact HTTPS Origins allowed to call refresh/logout; not a relay CORS allowlist | - | -| `TRUSTED_PROXIES` | Comma-separated reverse-proxy IPs/CIDRs trusted for client IP headers; unset trusts no proxies | - | +| `TRUSTED_PROXIES` | Unset/blank trusts loopback, RFC 1918 and IPv6 ULA with a startup warning; `none` trusts no proxies; an explicit proxy IP/CIDR list replaces the defaults | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` | | `USER_SESSION_ACTIVE_LIMIT` | Maximum active login Sessions per user | `50` | | `USER_SESSION_ISSUANCE_LIMIT` | Maximum Sessions created per user within the issuance window, including revoked Sessions | `100` | | `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Per-user Session issuance window; clamped to the revoked retention period when configured higher | `86400` | diff --git a/README.fr.md b/README.fr.md index 9522601b590a..38506ab693c7 100644 --- a/README.fr.md +++ b/README.fr.md @@ -316,7 +316,7 @@ docker run --name new-api -d --restart always \ | `SESSION_SECRET` | Secret de signature d’authentification, identique sur tous les nœuds | - | | `SESSION_COOKIE_SECURE` | `false`/non défini désactive l’OriginGuard de refresh/logout pour les proxys HTTP locaux ; `true` active le cookie Secure et le contrôle strict de l’Origin | `false` | | `SESSION_COOKIE_TRUSTED_URL` | Obligatoire en mode Secure : Origins HTTPS exactes autorisées pour refresh/logout, séparées par des virgules ; ce n’est pas une liste CORS relay | - | -| `TRUSTED_PROXIES` | IP/CIDR des proxys inverses autorisés à fournir l’IP client, séparés par des virgules ; aucun proxy n’est approuvé par défaut | - | +| `TRUSTED_PROXIES` | Variable absente/vide : approuve le bouclage, les réseaux RFC 1918 et l’ULA IPv6 avec un avertissement au démarrage ; `none` n’approuve aucun proxy ; une liste IP/CIDR explicite remplace les valeurs par défaut | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` | | `USER_SESSION_ACTIVE_LIMIT` | Nombre maximal de Sessions de connexion actives par utilisateur | `50` | | `USER_SESSION_ISSUANCE_LIMIT` | Nombre maximal de Sessions créées par utilisateur dans la fenêtre, y compris les Sessions révoquées | `100` | | `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Fenêtre de comptage des Sessions ; limitée à la durée de conservation des Sessions révoquées si elle est supérieure | `86400` | diff --git a/README.ja.md b/README.ja.md index 37b4862a69bb..e02ff4d6f51f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -318,7 +318,7 @@ docker run --name new-api -d --restart always \ | `SESSION_SECRET` | 認証署名シークレット。すべてのノードで同じ値が必要 | - | | `SESSION_COOKIE_SECURE` | `false`/未設定ではローカル HTTP 開発プロキシ向けに refresh/logout の OriginGuard を無効化し、`true` では Secure Cookie と厳格な Origin 検証を有効化 | `false` | | `SESSION_COOKIE_TRUSTED_URL` | Secure モードでは必須。refresh/logout を許可する完全一致の HTTPS Origin をカンマ区切りで指定。relay CORS 設定ではありません | - | -| `TRUSTED_PROXIES` | クライアント IP ヘッダーを信頼するリバースプロキシの IP/CIDR。カンマ区切りで指定し、未設定時はプロキシを信頼しません | - | +| `TRUSTED_PROXIES` | 未設定/空ではループバック、RFC 1918、IPv6 ULA を信頼して起動時に警告し、`none` ではすべて無効、明示的なプロキシ IP/CIDR リストは既定値を完全に置き換えます | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` | | `USER_SESSION_ACTIVE_LIMIT` | 1 ユーザーあたりの有効なログイン Session 上限 | `50` | | `USER_SESSION_ISSUANCE_LIMIT` | カウント期間内に作成できる Session 数の上限(取り消し済みを含む) | `100` | | `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 発行のカウント期間(秒)。取り消し済み Session の保持期間を超える場合は自動的に制限 | `86400` | diff --git a/README.md b/README.md index 9a35cc91212d..118f7ea52fd6 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ docker run --name new-api -d --restart always \ | `SESSION_SECRET` | Authentication signing secret; must be identical on every node | - | | `SESSION_COOKIE_SECURE` | `false`/unset disables the refresh/logout OriginGuard for local HTTP dev proxies; `true` enables the Secure cookie and strict Origin checks | `false` | | `SESSION_COOKIE_TRUSTED_URL` | Required with Secure mode: comma-separated exact HTTPS Origins allowed to call refresh/logout; not a relay CORS allowlist | - | -| `TRUSTED_PROXIES` | Comma-separated reverse-proxy IPs/CIDRs trusted for client IP headers; unset trusts no proxies | - | +| `TRUSTED_PROXIES` | Unset/blank trusts loopback, RFC 1918 and IPv6 ULA with a startup warning; `none` trusts no proxies; an explicit proxy IP/CIDR list replaces the defaults | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` | | `USER_SESSION_ACTIVE_LIMIT` | Maximum active login Sessions per user | `50` | | `USER_SESSION_ISSUANCE_LIMIT` | Maximum Sessions created per user within the issuance window, including revoked Sessions | `100` | | `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Per-user Session issuance window; clamped to the revoked retention period when configured higher | `86400` | diff --git a/README.zh_CN.md b/README.zh_CN.md index 6323c4e4255a..fddef3d35001 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -316,7 +316,7 @@ docker run --name new-api -d --restart always \ | `SESSION_SECRET` | 鉴权签名密钥;所有节点必须保持一致 | - | | `SESSION_COOKIE_SECURE` | `false`/未配置时关闭 refresh/logout OriginGuard 以兼容本地 HTTP 开发代理;`true` 时启用 Secure Cookie 和严格 Origin 校验 | `false` | | `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:允许调用 refresh/logout 的精确 HTTPS Origin,多个用英文逗号分隔;不是 relay CORS 白名单 | - | -| `TRUSTED_PROXIES` | 允许提供客户端 IP 请求头的可信反向代理 IP/CIDR,多个用逗号分隔;未配置时不信任任何代理 | - | +| `TRUSTED_PROXIES` | 未配置/留空时信任回环、RFC1918 和 IPv6 ULA 并输出启动告警;`none` 不信任任何代理;显式代理 IP/CIDR 列表完全替代默认值 | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` | | `USER_SESSION_ACTIVE_LIMIT` | 单用户最大活跃登录 Session 数 | `50` | | `USER_SESSION_ISSUANCE_LIMIT` | 单用户在签发窗口内可创建的 Session 总数,包含已撤销 Session | `100` | | `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 签发计数窗口(秒);高于 revoked 保留期时自动钳制 | `86400` | diff --git a/README.zh_TW.md b/README.zh_TW.md index abf1d2fc0bbd..4363cc06fb12 100644 --- a/README.zh_TW.md +++ b/README.zh_TW.md @@ -316,7 +316,7 @@ docker run --name new-api -d --restart always \ | `SESSION_SECRET` | 鑑權簽章密鑰;所有節點必須保持一致 | - | | `SESSION_COOKIE_SECURE` | `false`/未設定時關閉 refresh/logout OriginGuard 以相容本機 HTTP 開發代理;`true` 時啟用 Secure Cookie 和嚴格 Origin 驗證 | `false` | | `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:允許呼叫 refresh/logout 的精確 HTTPS Origin,多個值以英文逗號分隔;不是 relay CORS 白名單 | - | -| `TRUSTED_PROXIES` | 允許提供用戶端 IP 請求標頭的可信反向代理 IP/CIDR,多個值以逗號分隔;未設定時不信任任何代理 | - | +| `TRUSTED_PROXIES` | 未設定/留空時信任本機回送、RFC1918 和 IPv6 ULA 並輸出啟動警告;`none` 不信任任何代理;明確指定的代理 IP/CIDR 清單會完整取代預設值 | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` | | `USER_SESSION_ACTIVE_LIMIT` | 單一用戶最大活躍登入 Session 數 | `50` | | `USER_SESSION_ISSUANCE_LIMIT` | 單一用戶在簽發視窗內可建立的 Session 總數,包含已撤銷 Session | `100` | | `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 簽發計數視窗(秒);高於 revoked 保留期時自動限制 | `86400` | diff --git a/docker-compose.yml b/docker-compose.yml index 279861e8e6a6..8e6fe4b57b6d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,7 +41,7 @@ services: # - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!) # - SESSION_COOKIE_SECURE=true # true:启用 Secure Refresh Cookie 和严格 refresh/logout OriginGuard;false/未配置:关闭 OriginGuard,仅用于本地 HTTP (true: Secure cookie + strict refresh/logout OriginGuard; false/unset: guard disabled for local HTTP only) # - SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com # Secure=true 时必填的精确 HTTPS Origin;不是 relay CORS 白名单,不支持通配符/路径 (Required exact HTTPS origins when Secure=true; not a relay CORS allowlist, no wildcard/path) -# - TRUSTED_PROXIES=172.16.0.0/12 # 可信反向代理 IP/CIDR;未配置时忽略 X-Forwarded-For (Trusted reverse-proxy IPs/CIDRs; X-Forwarded-For is ignored when unset) +# - TRUSTED_PROXIES=172.20.0.0/16 # 未配置时信任回环/RFC1918/fc00::/7 并告警,none 为严格模式,显式列表替代默认值 (Unset trusts loopback/RFC 1918/fc00::/7 with a warning; none trusts no proxies; an explicit list replaces defaults) # - USER_SESSION_ACTIVE_LIMIT=50 # - USER_SESSION_ISSUANCE_LIMIT=100 # - USER_SESSION_ISSUANCE_WINDOW_SECONDS=86400 # 不得大于 revoked 保留期 (must not exceed revoked retention) diff --git a/docs/authentication.md b/docs/authentication.md index 1f2ed30e46ef..022928693621 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -124,11 +124,13 @@ SESSION_COOKIE_TRUSTED_URL=https://panel.example.com,https://admin.example.com ## 可信代理与 IP 限流 -Gin 默认会信任所有代理提供的客户端 IP 请求头,本项目不再使用该默认值: +Gin 默认会信任所有代理提供的客户端 IP 请求头。本项目改为兼顾常见反代拓扑和公网直连安全的三态配置: -- 未配置 `TRUSTED_PROXIES` 时不信任任何代理,`ClientIP()` 只使用直连地址,客户端自行伪造 `X-Forwarded-For` 不会改变限流桶。 -- 反向代理部署应将代理自身的 IP 或 CIDR 以英文逗号分隔写入 `TRUSTED_PROXIES`,不要填客户端网段。非空但无效的配置会阻止服务启动。 -- 升级后如未在反代部署中配置该变量,限流和 Session 审计 IP 会记录代理地址。 +- 未配置、空字符串或纯空白的 `TRUSTED_PROXIES` 默认信任 `127.0.0.0/8`、`::1`、`10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16` 和 `fc00::/7`,并输出启动告警。该默认值覆盖同机 Nginx、Docker Compose 和常见内网反代;公网直连地址不在列表中,其伪造的 `X-Forwarded-For` 会被忽略。 +- `TRUSTED_PROXIES=none`(大小写不敏感且必须单独使用)启用严格直连模式,不信任任何代理,`ClientIP()` 只使用 TCP 直连地址。 +- 其他非空值按英文逗号解析为代理 IP/CIDR,并完全替代默认列表。应填写反向代理自身的地址而不是客户端网段;非法 CIDR、空列表或将 `none` 与其他值混用都会阻止服务启动。 + +Gin 只在请求的直连来源属于可信代理时解析客户端 IP 请求头,并从转发链右侧向左寻找首个非可信地址。因此常见 Nginx `$proxy_add_x_forwarded_for` 链中的公网客户端地址会阻止更左侧的伪造前缀生效。默认信任私网的残余风险是:能够从同一私网直接访问应用的其他机器或容器仍可伪造这些请求头;需要消除此风险时应使用 `none` 或配置精确代理地址。 Redis 限流使用原子 Lua 固定窗口,替代旧的近似滑动窗口 List 实现。这是有意的语义变化:窗口边界两侧可分别打满一次,极短时间内通过量最高约为配置值的两倍。例如 `20 次/20 分钟` 在边界可通过约 40 次。帐户级 Session 上限和签发窗口继续控制数据库增长;如未来需要严格抑制边界突发,需单独迁移为 ZSET 滑动窗口。 @@ -162,6 +164,6 @@ Proof 同时绑定用户、登录会话、用户鉴权版本、会话版本和 s - 数据库迁移会新增 `user_sessions`、`auth_flows`、`external_identity_claims` 和 `users.auth_version`,并为已有用户初始化鉴权版本、回填 Telegram 账号唯一归属;若历史数据中同一 Telegram ID 已绑定多个用户,迁移会拒绝继续启动,需先消除歧义。 - 数据库迁移会为 Session 签发计数和分批清理新增索引;已有 `user_sessions` 很大时应为首次启动预留维护窗口。 - 仅 master 节点定时清理过期登录会话、超过配置保留期的 revoked 会话和已过保留期的 AuthFlow。 -- 反向代理部署在升级前必须配置 `TRUSTED_PROXIES`,否则所有请求会按代理的直连 IP 限流。 +- 未配置 `TRUSTED_PROXIES` 时会兼容信任回环和常见私网代理;使用公网负载均衡器、`100.64.0.0/10`、链路本地地址或自定义 CNI 网段的部署仍需显式配置。需要严格忽略所有转发头时设置为 `none`。 - Redis 限流从近似滑动窗口改为原子固定窗口,存在明确的边界双倍突发语义。 - 自建客户端应按新的 AuthBundle、`flow_token` 和 Security Proof 契约升级;PAT 客户端可直接移除 `New-Api-User`。 diff --git a/trusted_proxies.go b/trusted_proxies.go index 6aff2ba51a41..f4b338a6cfa2 100644 --- a/trusted_proxies.go +++ b/trusted_proxies.go @@ -3,17 +3,29 @@ package main import ( "errors" "fmt" + "log" "os" "strings" "github.com/gin-gonic/gin" ) +var defaultTrustedProxyCIDRs = []string{ + "127.0.0.0/8", + "::1", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7", +} + func configureTrustedProxies(engine *gin.Engine) error { - rawTrustedProxies := os.Getenv("TRUSTED_PROXIES") - if strings.TrimSpace(rawTrustedProxies) == "" { - // Gin trusts all proxies by default. An explicit nil default prevents a - // direct client from spoofing X-Forwarded-For to evade IP rate limits. + rawTrustedProxies := strings.TrimSpace(os.Getenv("TRUSTED_PROXIES")) + if rawTrustedProxies == "" { + log.Print("WARNING: TRUSTED_PROXIES is unset or blank; trusting loopback, RFC 1918, and IPv6 ULA proxy addresses for compatibility. Set TRUSTED_PROXIES=none to trust no proxies, or configure explicit proxy IPs/CIDRs to replace these defaults.") + return engine.SetTrustedProxies(defaultTrustedProxyCIDRs) + } + if strings.EqualFold(rawTrustedProxies, "none") { return engine.SetTrustedProxies(nil) } @@ -21,9 +33,13 @@ func configureTrustedProxies(engine *gin.Engine) error { trustedProxies := make([]string, 0, len(parts)) for _, part := range parts { trustedProxy := strings.TrimSpace(part) - if trustedProxy != "" { - trustedProxies = append(trustedProxies, trustedProxy) + if trustedProxy == "" { + continue + } + if strings.EqualFold(trustedProxy, "none") { + return errors.New("TRUSTED_PROXIES=none must be used alone") } + trustedProxies = append(trustedProxies, trustedProxy) } if len(trustedProxies) == 0 { return errors.New("TRUSTED_PROXIES does not contain an IP address or CIDR") diff --git a/trusted_proxies_test.go b/trusted_proxies_test.go index db3c9bca1fdb..642b566e831d 100644 --- a/trusted_proxies_test.go +++ b/trusted_proxies_test.go @@ -29,27 +29,79 @@ func newClientIPRouter() *gin.Engine { return router } -func TestConfigureTrustedProxiesDefaultsToNoTrust(t *testing.T) { +func TestConfigureTrustedProxiesDefaultsToLoopbackAndPrivateNetworks(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("TRUSTED_PROXIES", "") router := newClientIPRouter() require.NoError(t, configureTrustedProxies(router)) + testCases := []struct { + name string + remoteAddr string + }{ + {name: "IPv4 loopback", remoteAddr: "127.0.0.1:12345"}, + {name: "IPv6 loopback", remoteAddr: "[::1]:12345"}, + {name: "10 private network", remoteAddr: "10.20.30.40:12345"}, + {name: "172 private network", remoteAddr: "172.20.0.2:12345"}, + {name: "192 private network", remoteAddr: "192.168.10.2:12345"}, + {name: "IPv6 unique local network", remoteAddr: "[fd12:3456::2]:12345"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + clientIP := requestClientIP(router, testCase.remoteAddr, "203.0.113.10") + assert.Equal(t, "203.0.113.10", clientIP) + }) + } +} + +func TestConfigureTrustedProxiesDefaultRejectsPublicPeerHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("TRUSTED_PROXIES", " \t ") + router := newClientIPRouter() + require.NoError(t, configureTrustedProxies(router)) + clientIP := requestClientIP(router, "198.51.100.10:12345", "203.0.113.10") - assert.Equal(t, "198.51.100.10", clientIP, "an unconfigured proxy must not make a spoofed X-Forwarded-For authoritative") + assert.Equal(t, "198.51.100.10", clientIP, "a public peer must not make a spoofed X-Forwarded-For authoritative") +} + +func TestConfigureTrustedProxiesDefaultStopsAtPublicClientInForwardedChain(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("TRUSTED_PROXIES", "") + router := newClientIPRouter() + require.NoError(t, configureTrustedProxies(router)) + + clientIP := requestClientIP(router, "172.20.0.2:12345", "192.0.2.99, 203.0.113.10") + assert.Equal(t, "203.0.113.10", clientIP, "the first public hop from the trusted proxy must win over a client-supplied prefix") +} + +func TestConfigureTrustedProxiesNoneDisablesForwardedHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("TRUSTED_PROXIES", " NoNe ") + router := newClientIPRouter() + require.NoError(t, configureTrustedProxies(router)) + + clientIP := requestClientIP(router, "127.0.0.1:12345", "203.0.113.10") + assert.Equal(t, "127.0.0.1", clientIP) } func TestConfigureTrustedProxiesAcceptsTrimmedIPsAndCIDRs(t *testing.T) { gin.SetMode(gin.TestMode) - t.Setenv("TRUSTED_PROXIES", " 192.0.2.0/24, 127.0.0.1 ") + t.Setenv("TRUSTED_PROXIES", " 192.0.2.0/24, 198.51.100.30 ") router := newClientIPRouter() require.NoError(t, configureTrustedProxies(router)) trustedClientIP := requestClientIP(router, "192.0.2.10:12345", "203.0.113.20") assert.Equal(t, "203.0.113.20", trustedClientIP) - untrustedClientIP := requestClientIP(router, "198.51.100.20:12345", "203.0.113.21") + trustedExactIP := requestClientIP(router, "198.51.100.30:12345", "203.0.113.21") + assert.Equal(t, "203.0.113.21", trustedExactIP) + + untrustedClientIP := requestClientIP(router, "198.51.100.20:12345", "203.0.113.22") assert.Equal(t, "198.51.100.20", untrustedClientIP) + + defaultProxyIP := requestClientIP(router, "127.0.0.1:12345", "203.0.113.23") + assert.Equal(t, "127.0.0.1", defaultProxyIP, "an explicit list must replace, not extend, the compatibility defaults") } func TestConfigureTrustedProxiesRejectsInvalidConfiguration(t *testing.T) { @@ -61,6 +113,8 @@ func TestConfigureTrustedProxiesRejectsInvalidConfiguration(t *testing.T) { {name: "no entries", value: ", ,"}, {name: "invalid entry", value: "not-an-ip"}, {name: "mixed valid and invalid entries", value: "127.0.0.1, not-an-ip"}, + {name: "none mixed with valid entry", value: "none,127.0.0.1"}, + {name: "valid entry mixed with none", value: "127.0.0.1,NONE"}, } for _, testCase := range testCases { From 99fa20c23048a1089cff79044f15f6b0db6b5c1f Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 20 Jul 2026 14:27:42 +0800 Subject: [PATCH 4/5] refactor: address dashboard auth review feedback --- controller/telegram.go | 131 ++++++--- controller/telegram_test.go | 274 +++++++++++++++++- docs/authentication.md | 4 + docs/openapi/api.json | 16 +- middleware/auth.go | 50 +++- middleware/auth_test.go | 171 ++++++++++- middleware/header_nav_test.go | 22 ++ middleware/model-rate-limit.go | 4 +- middleware/model_rate_limit_test.go | 34 +++ model/user_session.go | 8 +- model/user_session_migration_test.go | 154 ++++++++++ model/user_session_test.go | 66 +++++ .../src/components/sign-out-dialog.tsx | 12 +- .../features/auth/hooks/use-auth-redirect.ts | 29 +- .../features/auth/lib/auth-redirect.test.ts | 98 +++++++ .../src/features/auth/lib/auth-redirect.ts | 80 +++++ .../auth/lib/oauth-bind-window.test.ts | 99 +++++++ .../features/auth/lib/oauth-bind-window.ts | 60 ++++ .../features/auth/secure-verification/api.ts | 5 +- .../hooks/use-secure-verification.ts | 2 +- .../sign-in/components/user-auth-form.tsx | 6 +- .../dialogs/telegram-bind-dialog.tsx | 6 +- .../components/login-sessions-card.tsx | 8 +- web/default/src/i18n/locales/en.json | 9 + web/default/src/i18n/locales/fr.json | 11 +- web/default/src/i18n/locales/ja.json | 9 + web/default/src/i18n/locales/ru.json | 11 +- web/default/src/i18n/locales/vi.json | 9 + web/default/src/i18n/locales/zh-TW.json | 9 + web/default/src/i18n/locales/zh.json | 9 + web/default/src/i18n/static-keys.ts | 9 + web/default/src/lib/api.ts | 1 + web/default/src/lib/auth-session.test.ts | 38 +++ web/default/src/lib/auth-session.ts | 9 + .../src/lib/server-error-message.test.ts | 30 ++ web/default/src/lib/server-error-message.ts | 11 + web/default/src/routes/(auth)/oauth.tsx | 7 +- web/default/src/routes/(auth)/sign-in.tsx | 8 +- web/default/src/routes/__root.tsx | 9 +- web/default/src/routes/oauth/$provider.tsx | 74 ++--- 40 files changed, 1457 insertions(+), 145 deletions(-) create mode 100644 middleware/model_rate_limit_test.go create mode 100644 model/user_session_migration_test.go create mode 100644 web/default/src/features/auth/lib/auth-redirect.test.ts create mode 100644 web/default/src/features/auth/lib/auth-redirect.ts diff --git a/controller/telegram.go b/controller/telegram.go index 13e522599bce..104f7a8151c8 100644 --- a/controller/telegram.go +++ b/controller/telegram.go @@ -27,12 +27,22 @@ const ( telegramAuthorizationMaxAge = 5 * time.Minute telegramAuthorizationFutureSkew = 2 * time.Minute telegramBindFlowTTL = 5 * time.Minute + + telegramBindErrorDisabled = "TELEGRAM_BIND_DISABLED" + telegramBindErrorInvalidRequest = "TELEGRAM_BIND_INVALID_REQUEST" + telegramBindErrorFlowInvalid = "TELEGRAM_BIND_FLOW_INVALID" + telegramBindErrorSessionInvalid = "TELEGRAM_BIND_SESSION_INVALID" + telegramBindErrorAlreadyBound = "TELEGRAM_BIND_ALREADY_BOUND" + telegramBindErrorUserDeleted = "TELEGRAM_BIND_USER_DELETED" + telegramBindErrorUserDisabled = "TELEGRAM_BIND_USER_DISABLED" + telegramBindErrorInternal = "TELEGRAM_BIND_INTERNAL_ERROR" ) var ( - errTelegramAccountAlreadyBound = errors.New("telegram account is already bound") - errTelegramBindUserDeleted = errors.New("telegram bind user was deleted") - errTelegramBindUserDisabled = errors.New("telegram bind user is disabled") + errTelegramAccountAlreadyBound = errors.New("telegram account is already bound") + errTelegramBindAssertionInvalid = errors.New("telegram bind assertion is invalid") + errTelegramBindUserDeleted = errors.New("telegram bind user was deleted") + errTelegramBindUserDisabled = errors.New("telegram bind user is disabled") ) func TelegramBindStart(c *gin.Context) { @@ -73,43 +83,69 @@ func TelegramBindStart(c *gin.Context) { func TelegramBind(c *gin.Context) { if !common.TelegramOAuthEnabled { - c.JSON(http.StatusOK, gin.H{ - "message": "管理员未开启通过 Telegram 登录以及注册", - "success": false, - }) + telegramBindFailure( + c, + http.StatusOK, + "管理员未开启通过 Telegram 登录以及注册", + telegramBindErrorDisabled, + ) return } params := c.Request.URL.Query() telegramId, err := verifyTelegramAuthorization(params, common.TelegramBotToken, time.Now()) if err != nil { common.SysLog("TelegramBind authorization failed: " + err.Error()) - c.JSON(200, gin.H{ - "message": "无效的请求", - "success": false, - }) + telegramBindFailure(c, http.StatusOK, "无效的请求", telegramBindErrorInvalidRequest) return } pendingFlow, err := model.GetAuthFlow(c.Param("flow_token"), model.AuthFlowMatch{ Purpose: model.AuthFlowPurposeTelegramBind, }) if err != nil { - c.JSON(http.StatusForbidden, gin.H{ - "message": "绑定流程已过期或已使用", - "success": false, - }) + if common.GetTheme() == "default" && + !errors.Is(err, model.ErrAuthFlowInvalid) && + !errors.Is(err, model.ErrAuthFlowExpired) && + !errors.Is(err, model.ErrAuthFlowConsumed) { + common.SysError("TelegramBind flow lookup failed: " + err.Error()) + telegramBindFailure(c, http.StatusOK, "", telegramBindErrorInternal) + return + } + telegramBindFailure(c, http.StatusForbidden, "绑定流程已过期或已使用", telegramBindErrorFlowInvalid) return } if _, err := service.ValidateSessionReference(pendingFlow.UserId, pendingFlow.SessionId); err != nil { - c.JSON(http.StatusForbidden, gin.H{ - "message": "创建绑定的登录会话已失效", - "success": false, - }) + if common.GetTheme() != "default" { + telegramBindFailure(c, http.StatusForbidden, "创建绑定的登录会话已失效", telegramBindErrorSessionInvalid) + return + } + if !errors.Is(err, service.ErrLoginSessionInvalid) && + !errors.Is(err, service.ErrLoginSessionRevoked) && + !errors.Is(err, model.ErrUserSessionInactive) && + !errors.Is(err, gorm.ErrRecordNotFound) { + common.SysError("TelegramBind session validation failed: " + err.Error()) + telegramBindFailure(c, http.StatusOK, "", telegramBindErrorInternal) + return + } + + var user model.User + userErr := model.DB.First(&user, pendingFlow.UserId).Error + switch { + case errors.Is(userErr, gorm.ErrRecordNotFound): + telegramBindFailure(c, http.StatusOK, "用户已注销", telegramBindErrorUserDeleted) + case userErr != nil: + common.SysError("TelegramBind user status lookup failed: " + userErr.Error()) + telegramBindFailure(c, http.StatusOK, "", telegramBindErrorInternal) + case user.Status != common.UserStatusEnabled: + telegramBindFailure(c, http.StatusForbidden, "用户已被禁用", telegramBindErrorUserDisabled) + default: + telegramBindFailure(c, http.StatusForbidden, "创建绑定的登录会话已失效", telegramBindErrorSessionInvalid) + } return } assertion, assertionExpiresAt, err := telegramAuthorizationClaim(params, time.Now()) if err != nil { common.SysLog("TelegramBind authorization claim failed: " + err.Error()) - c.JSON(http.StatusForbidden, gin.H{"message": "无效的请求", "success": false}) + telegramBindFailure(c, http.StatusForbidden, "无效的请求", telegramBindErrorInvalidRequest) return } _, err = model.ConsumeAuthFlowWithAction(c.Param("flow_token"), model.AuthFlowMatch{ @@ -118,17 +154,12 @@ func TelegramBind(c *gin.Context) { SessionId: pendingFlow.SessionId, }, func(tx *gorm.DB, flow *model.AuthFlow) error { if err := model.ClaimExternalAuthAssertionWithTx(tx, model.AuthFlowPurposeTelegramAssertion, assertion, assertionExpiresAt); err != nil { + if errors.Is(err, model.ErrAuthFlowInvalid) || errors.Is(err, model.ErrAuthFlowConsumed) { + return errors.Join(errTelegramBindAssertionInvalid, err) + } return err } - var session model.UserSession - if err := tx.Where("sid = ? AND user_id = ?", flow.SessionId, flow.UserId).First(&session).Error; err != nil { - return service.ErrLoginSessionRevoked - } - if session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= time.Now().Unix() { - return service.ErrLoginSessionRevoked - } - var user model.User if err := tx.First(&user, flow.UserId).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -139,6 +170,17 @@ func TelegramBind(c *gin.Context) { if user.Status != common.UserStatusEnabled { return errTelegramBindUserDisabled } + + var session model.UserSession + if err := tx.Where("sid = ? AND user_id = ?", flow.SessionId, flow.UserId).First(&session).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return service.ErrLoginSessionRevoked + } + return err + } + if session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= time.Now().Unix() { + return service.ErrLoginSessionRevoked + } if session.UserAuthVersion != user.AuthVersion { return service.ErrLoginSessionRevoked } @@ -169,18 +211,25 @@ func TelegramBind(c *gin.Context) { }) if err != nil { switch { + case errors.Is(err, errTelegramBindAssertionInvalid): + telegramBindFailure(c, http.StatusForbidden, "绑定流程已过期或已使用", telegramBindErrorInvalidRequest) case errors.Is(err, errTelegramAccountAlreadyBound): - c.JSON(http.StatusOK, gin.H{"message": "该 Telegram 账户已被绑定", "success": false}) + telegramBindFailure(c, http.StatusOK, "该 Telegram 账户已被绑定", telegramBindErrorAlreadyBound) case errors.Is(err, errTelegramBindUserDeleted): - c.JSON(http.StatusOK, gin.H{"message": "用户已注销", "success": false}) + telegramBindFailure(c, http.StatusOK, "用户已注销", telegramBindErrorUserDeleted) case errors.Is(err, errTelegramBindUserDisabled): - c.JSON(http.StatusForbidden, gin.H{"message": "用户已被禁用", "success": false}) + telegramBindFailure(c, http.StatusForbidden, "用户已被禁用", telegramBindErrorUserDisabled) case errors.Is(err, service.ErrLoginSessionRevoked): - c.JSON(http.StatusForbidden, gin.H{"message": "创建绑定的登录会话已失效", "success": false}) + telegramBindFailure(c, http.StatusForbidden, "创建绑定的登录会话已失效", telegramBindErrorSessionInvalid) case errors.Is(err, model.ErrAuthFlowInvalid), errors.Is(err, model.ErrAuthFlowExpired), errors.Is(err, model.ErrAuthFlowConsumed): - c.JSON(http.StatusForbidden, gin.H{"message": "绑定流程已过期或已使用", "success": false}) + telegramBindFailure(c, http.StatusForbidden, "绑定流程已过期或已使用", telegramBindErrorFlowInvalid) default: - common.ApiError(c, err) + if common.GetTheme() == "default" { + common.SysError("TelegramBind failed: " + err.Error()) + telegramBindFailure(c, http.StatusOK, "", telegramBindErrorInternal) + } else { + common.ApiError(c, err) + } } return } @@ -193,6 +242,20 @@ func TelegramBind(c *gin.Context) { c.Redirect(http.StatusFound, "/console/personal") } +func telegramBindFailure(c *gin.Context, status int, message string, errorCode string) { + if common.GetTheme() != "default" { + c.JSON(status, gin.H{"message": message, "success": false}) + return + } + + query := url.Values{ + "telegram_bind": {"error"}, + "flow_token": {c.Param("flow_token")}, + "error_code": {errorCode}, + } + c.Redirect(http.StatusFound, "/oauth/telegram?"+query.Encode()) +} + func TelegramLogin(c *gin.Context) { if !common.TelegramOAuthEnabled { c.JSON(200, gin.H{ diff --git a/controller/telegram_test.go b/controller/telegram_test.go index 01dde0fbc3ba..33d7c791dc58 100644 --- a/controller/telegram_test.go +++ b/controller/telegram_test.go @@ -4,6 +4,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "errors" "net/http" "net/http/httptest" "net/url" @@ -92,6 +93,88 @@ func signTelegramAuthorization(token string, params url.Values) { params.Set("hash", hex.EncodeToString(mac.Sum(nil))) } +func createTelegramBindTestFlow(t *testing.T, db *gorm.DB, name string, status int, now time.Time) (*model.User, string) { + t.Helper() + user := &model.User{ + Username: name, Password: "password-placeholder", Role: common.RoleCommonUser, + Status: status, Group: "default", AuthVersion: 1, AffCode: name, + } + require.NoError(t, db.Create(user).Error) + session := &model.UserSession{ + SID: name + "-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion, + Status: model.UserSessionStatusActive, RefreshHash: name + "-refresh-hash", LoginMethod: "password", + CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), + } + require.NoError(t, model.CreateUserSession(session)) + flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID, + ExpiresAt: now.Add(time.Minute), + }) + require.NoError(t, err) + return user, flowToken +} + +func assertTelegramBindRedirect(t *testing.T, response *httptest.ResponseRecorder, flowToken, errorCode string) { + t.Helper() + require.Equal(t, http.StatusFound, response.Code) + location, err := url.Parse(response.Header().Get("Location")) + require.NoError(t, err) + assert.Equal(t, "/oauth/telegram", location.Path) + assert.Equal(t, "error", location.Query().Get("telegram_bind")) + assert.Equal(t, flowToken, location.Query().Get("flow_token")) + assert.Equal(t, errorCode, location.Query().Get("error_code")) + assert.Empty(t, location.Query().Get("error_description")) + assert.Empty(t, location.Query().Get("message")) +} + +func TestTelegramBindFailureResponseContract(t *testing.T) { + previousTheme := common.GetTheme() + t.Cleanup(func() { common.SetTheme(previousTheme) }) + + failures := []struct { + name string + status int + message string + errorCode string + }{ + {name: "disabled", status: http.StatusOK, message: "disabled message", errorCode: telegramBindErrorDisabled}, + {name: "invalid request", status: http.StatusForbidden, message: "invalid message", errorCode: telegramBindErrorInvalidRequest}, + {name: "invalid flow", status: http.StatusForbidden, message: "flow message", errorCode: telegramBindErrorFlowInvalid}, + {name: "invalid session", status: http.StatusForbidden, message: "session message", errorCode: telegramBindErrorSessionInvalid}, + {name: "already bound", status: http.StatusOK, message: "bound message", errorCode: telegramBindErrorAlreadyBound}, + {name: "deleted user", status: http.StatusOK, message: "deleted message", errorCode: telegramBindErrorUserDeleted}, + {name: "disabled user", status: http.StatusForbidden, message: "user disabled message", errorCode: telegramBindErrorUserDisabled}, + {name: "internal error", status: http.StatusInternalServerError, message: "database detail", errorCode: telegramBindErrorInternal}, + } + + for _, failure := range failures { + t.Run(failure.name+" default", func(t *testing.T) { + common.SetTheme("default") + response := httptest.NewRecorder() + context, _ := gin.CreateTestContext(response) + context.Params = gin.Params{{Key: "flow_token", Value: "flow token"}} + context.Request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/flow-token", nil) + + telegramBindFailure(context, failure.status, failure.message, failure.errorCode) + + assertTelegramBindRedirect(t, response, "flow token", failure.errorCode) + assert.NotContains(t, response.Header().Get("Location"), failure.message) + assert.NotContains(t, response.Body.String(), failure.message) + }) + + t.Run(failure.name+" classic", func(t *testing.T) { + common.SetTheme("classic") + response := httptest.NewRecorder() + context, _ := gin.CreateTestContext(response) + + telegramBindFailure(context, failure.status, failure.message, failure.errorCode) + + assert.Equal(t, failure.status, response.Code) + assert.JSONEq(t, `{"message":`+strconv.Quote(failure.message)+`,"success":false}`, response.Body.String()) + }) + } +} + func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) { previousDB := model.DB previousType := common.MainDatabaseType() @@ -145,11 +228,47 @@ func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) { params := signedTelegramAuthorization(common.TelegramBotToken, now) router := gin.New() router.GET("/api/oauth/telegram/bind/:flow_token", TelegramBind) - request := httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+flowToken+"?"+params.Encode(), nil) + + common.TelegramOAuthEnabled = false + request := httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/disabled-flow", nil) response := httptest.NewRecorder() router.ServeHTTP(response, request) + assertTelegramBindRedirect(t, response, "disabled-flow", telegramBindErrorDisabled) + common.TelegramOAuthEnabled = true + + request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/invalid-request", nil) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + assertTelegramBindRedirect(t, response, "invalid-request", telegramBindErrorInvalidRequest) + + request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/missing-flow?"+params.Encode(), nil) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + assertTelegramBindRedirect(t, response, "missing-flow", telegramBindErrorFlowInvalid) + + invalidSessionFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: "missing-session", + ExpiresAt: now.Add(time.Minute), + }) + require.NoError(t, err) + request = httptest.NewRequest( + http.MethodGet, + "/api/oauth/telegram/bind/"+invalidSessionFlowToken+"?"+params.Encode(), + nil, + ) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + assertTelegramBindRedirect(t, response, invalidSessionFlowToken, telegramBindErrorSessionInvalid) + invalidSessionFlow, err := model.GetAuthFlow(invalidSessionFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) + require.NoError(t, err) + assert.Nil(t, invalidSessionFlow.ConsumedAt) + + request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+flowToken+"?"+params.Encode(), nil) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) assert.Equal(t, http.StatusFound, response.Code) + assert.Equal(t, "/oauth/telegram?telegram_bind=success&flow_token="+url.QueryEscape(flowToken), response.Header().Get("Location")) var storedUser model.User require.NoError(t, db.First(&storedUser, user.Id).Error) assert.Equal(t, "123456", storedUser.TelegramId) @@ -168,7 +287,7 @@ func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) { request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+replayFlowToken+"?"+params.Encode(), nil) response = httptest.NewRecorder() router.ServeHTTP(response, request) - assert.Equal(t, http.StatusForbidden, response.Code) + assertTelegramBindRedirect(t, response, replayFlowToken, telegramBindErrorInvalidRequest) replayFlow, err := model.GetAuthFlow(replayFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) require.NoError(t, err) assert.Nil(t, replayFlow.ConsumedAt) @@ -200,7 +319,7 @@ func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) { ) response = httptest.NewRecorder() router.ServeHTTP(response, request) - assert.Equal(t, http.StatusOK, response.Code) + assertTelegramBindRedirect(t, response, competingFlowToken, telegramBindErrorAlreadyBound) require.NoError(t, db.First(competingUser, competingUser.Id).Error) assert.Empty(t, competingUser.TelegramId) @@ -214,4 +333,153 @@ func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) { competingAssertion, competingAssertionExpiry, )) + + disabledUser, disabledFlowToken := createTelegramBindTestFlow( + t, db, "telegram-bind-disabled-user", common.UserStatusDisabled, now, + ) + disabledParams := signedTelegramAuthorization(common.TelegramBotToken, now) + disabledParams.Set("id", "disabled-telegram-id") + disabledParams.Set("first_name", "Disabled") + signTelegramAuthorization(common.TelegramBotToken, disabledParams) + request = httptest.NewRequest( + http.MethodGet, + "/api/oauth/telegram/bind/"+disabledFlowToken+"?"+disabledParams.Encode(), + nil, + ) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + assertTelegramBindRedirect(t, response, disabledFlowToken, telegramBindErrorUserDisabled) + var storedDisabledUser model.User + require.NoError(t, db.First(&storedDisabledUser, disabledUser.Id).Error) + assert.Empty(t, storedDisabledUser.TelegramId) + disabledFlow, err := model.GetAuthFlow(disabledFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) + require.NoError(t, err) + assert.Nil(t, disabledFlow.ConsumedAt) + disabledAssertion, disabledAssertionExpiry, err := telegramAuthorizationClaim(disabledParams, time.Now()) + require.NoError(t, err) + require.NoError(t, model.ClaimExternalAuthAssertion( + model.AuthFlowPurposeTelegramAssertion, + disabledAssertion, + disabledAssertionExpiry, + )) + + deletedUser, deletedFlowToken := createTelegramBindTestFlow( + t, db, "telegram-bind-deleted-user", common.UserStatusEnabled, now, + ) + require.NoError(t, db.Delete(deletedUser).Error) + deletedParams := signedTelegramAuthorization(common.TelegramBotToken, now) + deletedParams.Set("id", "deleted-telegram-id") + deletedParams.Set("first_name", "Deleted") + signTelegramAuthorization(common.TelegramBotToken, deletedParams) + request = httptest.NewRequest( + http.MethodGet, + "/api/oauth/telegram/bind/"+deletedFlowToken+"?"+deletedParams.Encode(), + nil, + ) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + assertTelegramBindRedirect(t, response, deletedFlowToken, telegramBindErrorUserDeleted) + deletedFlow, err := model.GetAuthFlow(deletedFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) + require.NoError(t, err) + assert.Nil(t, deletedFlow.ConsumedAt) + deletedAssertion, deletedAssertionExpiry, err := telegramAuthorizationClaim(deletedParams, time.Now()) + require.NoError(t, err) + require.NoError(t, model.ClaimExternalAuthAssertion( + model.AuthFlowPurposeTelegramAssertion, + deletedAssertion, + deletedAssertionExpiry, + )) + + _, internalFlowToken := createTelegramBindTestFlow( + t, db, "telegram-bind-internal-error", common.UserStatusEnabled, now, + ) + internalParams := signedTelegramAuthorization(common.TelegramBotToken, now) + internalParams.Set("id", "internal-error-telegram-id") + internalParams.Set("first_name", "Internal") + signTelegramAuthorization(common.TelegramBotToken, internalParams) + forcedError := errors.New("forced telegram session query failure") + const callbackName = "test:telegram-bind-session-query-failure" + require.NoError(t, db.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement.Table != "user_sessions" { + return + } + if _, inTransaction := tx.Statement.ConnPool.(gorm.TxCommitter); inTransaction { + tx.AddError(forcedError) + } + })) + request = httptest.NewRequest( + http.MethodGet, + "/api/oauth/telegram/bind/"+internalFlowToken+"?"+internalParams.Encode(), + nil, + ) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + db.Callback().Query().Remove(callbackName) + assertTelegramBindRedirect(t, response, internalFlowToken, telegramBindErrorInternal) + assert.NotContains(t, response.Header().Get("Location"), forcedError.Error()) + internalFlow, err := model.GetAuthFlow(internalFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) + require.NoError(t, err) + assert.Nil(t, internalFlow.ConsumedAt) + internalAssertion, internalAssertionExpiry, err := telegramAuthorizationClaim(internalParams, time.Now()) + require.NoError(t, err) + require.NoError(t, model.ClaimExternalAuthAssertion( + model.AuthFlowPurposeTelegramAssertion, + internalAssertion, + internalAssertionExpiry, + )) + + classicUser := &model.User{ + Username: "telegram-bind-classic-user", Password: "password-placeholder", Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "telegram-bind-classic-user", + } + require.NoError(t, db.Create(classicUser).Error) + classicSession := &model.UserSession{ + SID: "telegram-bind-classic-session", UserID: classicUser.Id, Version: 1, + UserAuthVersion: classicUser.AuthVersion, Status: model.UserSessionStatusActive, + RefreshHash: "classic-refresh-hash", LoginMethod: "password", + CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), + } + require.NoError(t, model.CreateUserSession(classicSession)) + classicFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeTelegramBind, UserId: classicUser.Id, SessionId: classicSession.SID, + ExpiresAt: now.Add(time.Minute), + }) + require.NoError(t, err) + classicParams := signedTelegramAuthorization(common.TelegramBotToken, now) + classicParams.Set("id", "987654") + signTelegramAuthorization(common.TelegramBotToken, classicParams) + common.SetTheme("classic") + request = httptest.NewRequest( + http.MethodGet, + "/api/oauth/telegram/bind/"+classicFlowToken+"?"+classicParams.Encode(), + nil, + ) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + assert.Equal(t, http.StatusFound, response.Code) + assert.Equal(t, "/console/personal", response.Header().Get("Location")) + + classicReplayFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposeTelegramBind, UserId: classicUser.Id, SessionId: classicSession.SID, + ExpiresAt: now.Add(time.Minute), + }) + require.NoError(t, err) + request = httptest.NewRequest( + http.MethodGet, + "/api/oauth/telegram/bind/"+classicReplayFlowToken+"?"+classicParams.Encode(), + nil, + ) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + assert.Equal(t, http.StatusForbidden, response.Code) + assert.JSONEq(t, `{"message":"绑定流程已过期或已使用","success":false}`, response.Body.String()) + classicReplayFlow, err := model.GetAuthFlow(classicReplayFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) + require.NoError(t, err) + assert.Nil(t, classicReplayFlow.ConsumedAt) + + request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/classic-invalid", nil) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + assert.Equal(t, http.StatusOK, response.Code) + assert.JSONEq(t, `{"message":"无效的请求","success":false}`, response.Body.String()) } diff --git a/docs/authentication.md b/docs/authentication.md index 022928693621..b7b5f870ee00 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -134,6 +134,8 @@ Gin 只在请求的直连来源属于可信代理时解析客户端 IP 请求头 Redis 限流使用原子 Lua 固定窗口,替代旧的近似滑动窗口 List 实现。这是有意的语义变化:窗口边界两侧可分别打满一次,极短时间内通过量最高约为配置值的两倍。例如 `20 次/20 分钟` 在边界可通过约 40 次。帐户级 Session 上限和签发窗口继续控制数据库增长;如未来需要严格抑制边界突发,需单独迁移为 ZSET 滑动窗口。 +用户级模型成功请求限流仍使用原有 Redis List 近似滑动窗口,但列表时间戳统一写为 UTC。滚动升级期间,旧节点写入的本地时间字符串和新节点写入的 UTC 字符串无法从格式上区分,可能在一个模型限流窗口内临时误放行或误拒绝。所有节点升级完成并经过一个完整窗口后会自然收敛;本次升级不会切换 Key 或主动删除现有列表。 + 开放注册仍会受 Critical IP 限流保护,但分布式 IP 多账号攻击不能仅靠 IP 限流阻止。公网开放注册的部署应同时启用 Turnstile 和邮箱验证;更强的设备或多维风控需作为独立安全项目设计。 ## PAT 调用契约 @@ -163,7 +165,9 @@ Proof 同时绑定用户、登录会话、用户鉴权版本、会话版本和 s - 旧 `session` Cookie 不再使用;升级后现有面板登录会失效,用户需要重新登录。 - 数据库迁移会新增 `user_sessions`、`auth_flows`、`external_identity_claims` 和 `users.auth_version`,并为已有用户初始化鉴权版本、回填 Telegram 账号唯一归属;若历史数据中同一 Telegram ID 已绑定多个用户,迁移会拒绝继续启动,需先消除歧义。 - 数据库迁移会为 Session 签发计数和分批清理新增索引;已有 `user_sessions` 很大时应为首次启动预留维护窗口。 +- `user_sessions.previous_refresh_hash` 会从定长 `char(64)` 迁移为 `varchar(64)`。应用会兼容读取历史定长字段留下的空格填充;迁移后的目标结构必须保持幂等,连续启动不应反复执行列类型变更。 - 仅 master 节点定时清理过期登录会话、超过配置保留期的 revoked 会话和已过保留期的 AuthFlow。 - 未配置 `TRUSTED_PROXIES` 时会兼容信任回环和常见私网代理;使用公网负载均衡器、`100.64.0.0/10`、链路本地地址或自定义 CNI 网段的部署仍需显式配置。需要严格忽略所有转发头时设置为 `none`。 - Redis 限流从近似滑动窗口改为原子固定窗口,存在明确的边界双倍突发语义。 +- 用户级模型成功请求限流的 UTC 时间戳在滚动升级期间存在一个窗口的混合格式过渡,期间可能临时误放行或误拒绝。 - 自建客户端应按新的 AuthBundle、`flow_token` 和 Security Proof 契约升级;PAT 客户端可直接移除 `New-Api-User`。 diff --git a/docs/openapi/api.json b/docs/openapi/api.json index a6cdaae6dac0..fb59e44436ad 100644 --- a/docs/openapi/api.json +++ b/docs/openapi/api.json @@ -1293,7 +1293,7 @@ "get": { "summary": "完成 Telegram 绑定", "deprecated": false, - "description": "Telegram widget 回调;通过一次性 flow_token 关联并重新校验创建该流程的登录会话", + "description": "Telegram widget 回调;通过一次性 flow_token 关联并重新校验创建该流程的登录会话。Default 主题始终以 302 回到 /oauth/telegram:成功携带 telegram_bind=success,失败携带 telegram_bind=error、flow_token 和稳定 error_code(TELEGRAM_BIND_DISABLED、TELEGRAM_BIND_INVALID_REQUEST、TELEGRAM_BIND_FLOW_INVALID、TELEGRAM_BIND_SESSION_INVALID、TELEGRAM_BIND_ALREADY_BOUND、TELEGRAM_BIND_USER_DELETED、TELEGRAM_BIND_USER_DISABLED 或 TELEGRAM_BIND_INTERNAL_ERROR),不会透传底层错误文案。Classic 主题保留既有 JSON 错误响应和成功跳转。", "tags": [ "OAuth" ], @@ -1309,7 +1309,19 @@ ], "responses": { "302": { - "description": "绑定成功后重定向到个人设置", + "description": "Default 主题重定向到绑定结果回调;Classic 主题绑定成功后重定向到个人设置", + "headers": {} + }, + "200": { + "description": "Classic 主题的既有业务错误 JSON 响应", + "headers": {} + }, + "403": { + "description": "Classic 主题的既有无效流程、会话或授权 JSON 响应", + "headers": {} + }, + "500": { + "description": "Classic 主题的既有内部错误 JSON 响应", "headers": {} } }, diff --git a/middleware/auth.go b/middleware/auth.go index d259d715811b..170f5fb369b7 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -23,6 +23,14 @@ import ( const authIdentityContextKey = "auth_identity" +type dashboardCredentialKind int + +const ( + dashboardCredentialUnmatched dashboardCredentialKind = iota + dashboardCredentialInternal + dashboardCredentialPAT +) + func validUserInfo(username string, role int) bool { // check username is empty if strings.TrimSpace(username) == "" { @@ -69,14 +77,13 @@ func authHelper(c *gin.Context, minRole int) { func TryUserAuth() func(c *gin.Context) { return func(c *gin.Context) { - _, ok := authorizationToken(c.GetHeader("Authorization")) - if ok { - user, identity, useAccessToken, err := authenticateDashboardRequest(c) - if err != nil { - writeDashboardAuthError(c, err) - return - } - setDashboardAuthContext(c, user, identity, useAccessToken) + user, identity, credentialKind, err := classifyDashboardCredential(c) + if err != nil { + writeDashboardAuthError(c, err) + return + } + if credentialKind != dashboardCredentialUnmatched { + setDashboardAuthContext(c, user, identity, credentialKind == dashboardCredentialPAT) } c.Next() } @@ -130,33 +137,44 @@ func GetSessionAuthIdentity(c *gin.Context) (service.AuthIdentity, bool) { } func authenticateDashboardRequest(c *gin.Context) (*model.UserBase, service.AuthIdentity, bool, error) { + user, identity, credentialKind, err := classifyDashboardCredential(c) + if err != nil { + return nil, service.AuthIdentity{}, credentialKind == dashboardCredentialPAT, err + } + if credentialKind == dashboardCredentialUnmatched { + return nil, service.AuthIdentity{}, false, service.ErrAuthTokenInvalid + } + return user, identity, credentialKind == dashboardCredentialPAT, nil +} + +func classifyDashboardCredential(c *gin.Context) (*model.UserBase, service.AuthIdentity, dashboardCredentialKind, error) { raw, ok := authorizationToken(c.GetHeader("Authorization")) if !ok { - return nil, service.AuthIdentity{}, false, service.ErrAuthTokenInvalid + return nil, service.AuthIdentity{}, dashboardCredentialUnmatched, nil } identity, internal, err := service.ParseDashboardAccessToken(raw) if internal { if err != nil { - return nil, service.AuthIdentity{}, false, err + return nil, service.AuthIdentity{}, dashboardCredentialInternal, err } _, user, err := service.ValidateLoginSession(identity) if err != nil { - return nil, service.AuthIdentity{}, false, err + return nil, service.AuthIdentity{}, dashboardCredentialInternal, err } - return user, identity, false, nil + return user, identity, dashboardCredentialInternal, nil } patUser, err := model.ValidateAccessToken(raw) if err != nil { - return nil, service.AuthIdentity{}, true, err + return nil, service.AuthIdentity{}, dashboardCredentialPAT, err } if patUser == nil || patUser.Id <= 0 { - return nil, service.AuthIdentity{}, true, service.ErrAuthTokenInvalid + return nil, service.AuthIdentity{}, dashboardCredentialUnmatched, nil } user, err := model.GetUserCache(patUser.Id) if err != nil { - return nil, service.AuthIdentity{}, true, err + return nil, service.AuthIdentity{}, dashboardCredentialPAT, err } - return user, service.AuthIdentity{UserID: user.Id, UserAuthVersion: user.AuthVersion}, true, nil + return user, service.AuthIdentity{UserID: user.Id, UserAuthVersion: user.AuthVersion}, dashboardCredentialPAT, nil } func authorizationToken(header string) (string, bool) { diff --git a/middleware/auth_test.go b/middleware/auth_test.go index a42c09faa18a..db7fb343c416 100644 --- a/middleware/auth_test.go +++ b/middleware/auth_test.go @@ -1,15 +1,21 @@ package middleware import ( + "crypto/hmac" + "crypto/sha256" + "errors" + "fmt" "net/http" "net/http/httptest" "testing" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" + "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -23,7 +29,7 @@ func setupDashboardAuthMiddlewareTest(t *testing.T) { previousSecret := common.SessionSecret db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&model.User{})) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{})) model.DB = db common.SetMainDatabaseType(common.DatabaseTypeSQLite) common.RedisEnabled = false @@ -36,11 +42,43 @@ func setupDashboardAuthMiddlewareTest(t *testing.T) { }) } +func issueExpiredDashboardAccessToken(t *testing.T, identity service.AuthIdentity) string { + t.Helper() + claims := jwt.MapClaims{ + "iss": "new-api", + "aud": []string{"new-api-dashboard"}, + "sub": fmt.Sprintf("%d", identity.UserID), + "token_use": "access", + "sid": identity.SessionID, + "uv": identity.UserAuthVersion, + "sv": identity.SessionVersion, + "exp": time.Now().Add(-time.Minute).Unix(), + "nbf": time.Now().Add(-2 * time.Minute).Unix(), + "iat": time.Now().Add(-2 * time.Minute).Unix(), + } + mac := hmac.New(sha256.New, []byte(common.SessionSecret)) + _, err := mac.Write([]byte("new-api/auth/access/v1")) + require.NoError(t, err) + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(mac.Sum(nil)) + require.NoError(t, err) + return token +} + +func tamperDashboardToken(token string) string { + tamperAt := len(token) - 2 + replacement := "x" + if token[tamperAt] == 'x' { + replacement = "y" + } + return token[:tamperAt] + replacement + token[tamperAt+1:] +} + func createMiddlewarePATUser(t *testing.T, username, token string) *model.User { t.Helper() user := &model.User{ Username: username, Password: "password-placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AccessToken: &token, AuthVersion: 1, + AffCode: "middleware-aff-" + username, } require.NoError(t, model.DB.Create(user).Error) return user @@ -72,12 +110,7 @@ func TestUserAuthNeverFallsBackForRecognizedInvalidInternalJWT(t *testing.T) { identity := service.AuthIdentity{UserID: 42, SessionID: "session-42", UserAuthVersion: 1, SessionVersion: 1} token, _, err := service.IssueAccessToken(identity) require.NoError(t, err) - tamperAt := len(token) - 2 - replacement := "x" - if token[tamperAt] == 'x' { - replacement = "y" - } - tampered := token[:tamperAt] + replacement + token[tamperAt+1:] + tampered := tamperDashboardToken(token) createMiddlewarePATUser(t, "jwt-fallback-user", tampered) router := gin.New() router.GET("/protected", UserAuth(), func(c *gin.Context) { @@ -92,3 +125,127 @@ func TestUserAuthNeverFallsBackForRecognizedInvalidInternalJWT(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, response.Code) assert.Contains(t, response.Body.String(), "AUTH_UNAUTHORIZED") } + +func TestTryUserAuthCredentialClassification(t *testing.T) { + setupDashboardAuthMiddlewareTest(t) + gin.SetMode(gin.TestMode) + + patUser := createMiddlewarePATUser(t, "optional-pat-user", "optional.pat.with-dots") + internalUser := createMiddlewarePATUser(t, "optional-session-user", "unrelated-pat") + now := time.Now().Unix() + session := &model.UserSession{ + SID: "optional-auth-session", + UserID: internalUser.Id, + Version: 1, + UserAuthVersion: internalUser.AuthVersion, + Status: model.UserSessionStatusActive, + RefreshHash: "refresh-hash", + LoginMethod: "password", + LastActiveAt: now, + ExpiresAt: now + 3600, + } + require.NoError(t, model.CreateUserSession(session)) + identity := service.AuthIdentity{ + UserID: internalUser.Id, + SessionID: session.SID, + UserAuthVersion: session.UserAuthVersion, + SessionVersion: session.Version, + } + accessToken, _, err := service.IssueAccessToken(identity) + require.NoError(t, err) + securityProof, _, err := service.IssueSecurityProof(identity, "2fa", []string{"channel.key.read"}) + require.NoError(t, err) + externalToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": "external-issuer", + "aud": "external-audience", + "exp": time.Now().Add(time.Minute).Unix(), + }).SignedString([]byte("external-secret")) + require.NoError(t, err) + + router := gin.New() + router.GET("/optional", TryUserAuth(), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "id": c.GetInt("id"), + "use_access_token": c.GetBool("use_access_token"), + }) + }) + + tests := []struct { + name string + token string + wantStatus int + wantUserID int + wantPAT bool + wantErrorCode string + }{ + {name: "no authorization header", wantStatus: http.StatusOK}, + {name: "opaque unmatched credential", token: "opaque-relay-key", wantStatus: http.StatusOK}, + {name: "dotted unmatched credential", token: "ordinary.key.with-dots", wantStatus: http.StatusOK}, + {name: "third party jwt", token: externalToken, wantStatus: http.StatusOK}, + {name: "valid pat", token: "optional.pat.with-dots", wantStatus: http.StatusOK, wantUserID: patUser.Id, wantPAT: true}, + {name: "valid internal access jwt", token: accessToken, wantStatus: http.StatusOK, wantUserID: internalUser.Id}, + {name: "expired internal access jwt", token: issueExpiredDashboardAccessToken(t, identity), wantStatus: http.StatusUnauthorized, wantErrorCode: "AUTH_TOKEN_EXPIRED"}, + {name: "tampered internal access jwt", token: tamperDashboardToken(accessToken), wantStatus: http.StatusUnauthorized, wantErrorCode: "AUTH_UNAUTHORIZED"}, + {name: "security proof used as access", token: securityProof, wantStatus: http.StatusUnauthorized, wantErrorCode: "AUTH_UNAUTHORIZED"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/optional", nil) + if test.token != "" { + request.Header.Set("Authorization", "Bearer "+test.token) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + assert.Equal(t, test.wantStatus, response.Code) + if test.wantErrorCode != "" { + assert.Contains(t, response.Body.String(), test.wantErrorCode) + return + } + var body struct { + ID int `json:"id"` + UseAccessToken bool `json:"use_access_token"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + assert.Equal(t, test.wantUserID, body.ID) + assert.Equal(t, test.wantPAT, body.UseAccessToken) + }) + } + + requiredRouter := gin.New() + requiredRouter.GET("/required", UserAuth(), func(c *gin.Context) { c.Status(http.StatusNoContent) }) + requiredRequest := httptest.NewRequest(http.MethodGet, "/required", nil) + requiredRequest.Header.Set("Authorization", "Bearer ordinary-unmatched-key") + requiredResponse := httptest.NewRecorder() + requiredRouter.ServeHTTP(requiredResponse, requiredRequest) + assert.Equal(t, http.StatusUnauthorized, requiredResponse.Code, "required dashboard authentication must not adopt optional-auth fallback semantics") + + var patUserQueries int + forcedCacheError := errors.New("forced PAT user cache lookup failure") + const callbackName = "test:optional-auth-pat-user-cache-failure" + require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement.Table != "users" { + return + } + patUserQueries++ + if patUserQueries == 2 { + tx.AddError(forcedCacheError) + } + })) + cacheFailureRequest := httptest.NewRequest(http.MethodGet, "/optional", nil) + cacheFailureRequest.Header.Set("Authorization", "Bearer optional.pat.with-dots") + cacheFailureResponse := httptest.NewRecorder() + router.ServeHTTP(cacheFailureResponse, cacheFailureRequest) + model.DB.Callback().Query().Remove(callbackName) + assert.Equal(t, http.StatusInternalServerError, cacheFailureResponse.Code) + assert.Contains(t, cacheFailureResponse.Body.String(), "AUTH_INTERNAL_ERROR") + + sqlDB, err := model.DB.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + databaseFailureRequest := httptest.NewRequest(http.MethodGet, "/optional", nil) + databaseFailureRequest.Header.Set("Authorization", "Bearer database-failure-key") + databaseFailureResponse := httptest.NewRecorder() + router.ServeHTTP(databaseFailureResponse, databaseFailureRequest) + assert.Equal(t, http.StatusInternalServerError, databaseFailureResponse.Code) + assert.Contains(t, databaseFailureResponse.Body.String(), "AUTH_INTERNAL_ERROR") +} diff --git a/middleware/header_nav_test.go b/middleware/header_nav_test.go index 83852d3f6ad8..6ed0b0aa96d3 100644 --- a/middleware/header_nav_test.go +++ b/middleware/header_nav_test.go @@ -7,6 +7,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" @@ -165,3 +166,24 @@ func TestHeaderNavModulePublicOrUserAuthRequiresLoginForLegacyDisabledModule(t * require.Equal(t, http.StatusUnauthorized, recorder.Code) } + +func TestHeaderNavPublicRouteRejectsExpiredInternalAccessToken(t *testing.T) { + setupDashboardAuthMiddlewareTest(t) + withHeaderNavModules(t, "") + gin.SetMode(gin.TestMode) + + router := gin.New() + router.GET("/api/test", HeaderNavModuleAuth("pricing"), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) + }) + request := httptest.NewRequest(http.MethodGet, "/api/test", nil) + request.Header.Set("Authorization", "Bearer "+issueExpiredDashboardAccessToken(t, service.AuthIdentity{ + UserID: 1, SessionID: "expired-header-nav-session", UserAuthVersion: 1, SessionVersion: 1, + })) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + require.Equal(t, http.StatusUnauthorized, response.Code) + require.Contains(t, response.Body.String(), "AUTH_TOKEN_EXPIRED") +} diff --git a/middleware/model-rate-limit.go b/middleware/model-rate-limit.go index 87021393205b..9f1d94039685 100644 --- a/middleware/model-rate-limit.go +++ b/middleware/model-rate-limit.go @@ -47,7 +47,7 @@ func checkRedisRateLimit(ctx context.Context, rdb *redis.Client, key string, max return false, err } - nowTimeStr := time.Now().Format(modelRateLimitTimeFormat) + nowTimeStr := time.Now().UTC().Format(modelRateLimitTimeFormat) nowTime, err := time.Parse(modelRateLimitTimeFormat, nowTimeStr) if err != nil { return false, err @@ -69,7 +69,7 @@ func recordRedisRequest(ctx context.Context, rdb *redis.Client, key string, maxC return } - now := time.Now().Format(modelRateLimitTimeFormat) + now := time.Now().UTC().Format(modelRateLimitTimeFormat) rdb.LPush(ctx, key, now) rdb.LTrim(ctx, key, 0, int64(maxCount-1)) rdb.Expire(ctx, key, time.Duration(setting.ModelRequestRateLimitDurationMinutes)*time.Minute) diff --git a/middleware/model_rate_limit_test.go b/middleware/model_rate_limit_test.go new file mode 100644 index 000000000000..3e9923fdac15 --- /dev/null +++ b/middleware/model_rate_limit_test.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestModelRedisRateLimitUsesUTCRegardlessOfLocalTimezone(t *testing.T) { + redisServer, redisClient := useRateLimitMiniRedis(t) + previousLocation := time.Local + time.Local = time.FixedZone("test-utc-plus-eight", 8*60*60) + t.Cleanup(func() { time.Local = previousLocation }) + + ctx := context.Background() + recordKey := "rateLimit:model-utc-record" + recordRedisRequest(ctx, redisClient, recordKey, 2) + recorded, err := redisClient.LIndex(ctx, recordKey, 0).Result() + require.NoError(t, err) + recordedAt, err := time.Parse(modelRateLimitTimeFormat, recorded) + require.NoError(t, err) + assert.WithinDuration(t, time.Now().UTC(), recordedAt, 2*time.Second) + + checkKey := "rateLimit:model-utc-check" + withinWindow := time.Now().UTC().Add(-30 * time.Second).Format(modelRateLimitTimeFormat) + _, err = redisServer.Push(checkKey, withinWindow, withinWindow) + require.NoError(t, err) + allowed, err := checkRedisRateLimit(ctx, redisClient, checkKey, 2, 60) + require.NoError(t, err) + assert.False(t, allowed, "an existing UTC timestamp inside the window must remain limited on a non-UTC host") +} diff --git a/model/user_session.go b/model/user_session.go index 1003caeba352..69162d2e7567 100644 --- a/model/user_session.go +++ b/model/user_session.go @@ -5,6 +5,7 @@ import ( "crypto/hmac" "errors" "fmt" + "strings" "time" "github.com/QuantumNous/new-api/common" @@ -45,7 +46,7 @@ type UserSession struct { UserAuthVersion int64 `json:"user_auth_version" gorm:"type:bigint;not null"` Status string `json:"status" gorm:"type:varchar(16);not null;index:idx_user_sessions_user_status_expiry,priority:2;index:idx_user_sessions_status_revoked,priority:1"` RefreshHash string `json:"-" gorm:"type:char(64);not null"` - PreviousRefreshHash string `json:"-" gorm:"type:char(64)"` + PreviousRefreshHash string `json:"-" gorm:"type:varchar(64)"` PreviousValidUntil int64 `json:"-" gorm:"type:bigint;not null;default:0"` LoginMethod string `json:"login_method" gorm:"type:varchar(32);not null"` IP string `json:"ip" gorm:"type:varchar(64)"` @@ -61,6 +62,11 @@ func (UserSession) TableName() string { return "user_sessions" } +func (session *UserSession) AfterFind(_ *gorm.DB) error { + session.PreviousRefreshHash = strings.TrimSpace(session.PreviousRefreshHash) + return nil +} + type userSessionCacheEntry struct { SID string UserID int diff --git a/model/user_session_migration_test.go b/model/user_session_migration_test.go new file mode 100644 index 000000000000..ab0545c1f3aa --- /dev/null +++ b/model/user_session_migration_test.go @@ -0,0 +1,154 @@ +package model + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type previousRefreshHashMigrationLegacy struct { + SID string `gorm:"column:sid;type:varchar(64);primaryKey"` + PreviousRefreshHash string `gorm:"column:previous_refresh_hash;type:char(64)"` +} + +type previousRefreshHashMigrationTarget struct { + SID string `gorm:"column:sid;type:varchar(64);primaryKey"` + PreviousRefreshHash string `gorm:"column:previous_refresh_hash;type:varchar(64)"` +} + +type migrationSQLRecorder struct { + mu sync.Mutex + statements []string +} + +func (recorder *migrationSQLRecorder) LogMode(logger.LogLevel) logger.Interface { return recorder } +func (recorder *migrationSQLRecorder) Info(context.Context, string, ...any) {} +func (recorder *migrationSQLRecorder) Warn(context.Context, string, ...any) {} +func (recorder *migrationSQLRecorder) Error(context.Context, string, ...any) {} + +func (recorder *migrationSQLRecorder) Trace(_ context.Context, _ time.Time, sql func() (string, int64), _ error) { + statement, _ := sql() + recorder.mu.Lock() + recorder.statements = append(recorder.statements, statement) + recorder.mu.Unlock() +} + +func (recorder *migrationSQLRecorder) reset() { + recorder.mu.Lock() + recorder.statements = nil + recorder.mu.Unlock() +} + +func (recorder *migrationSQLRecorder) schemaMutations() []string { + recorder.mu.Lock() + defer recorder.mu.Unlock() + mutations := make([]string, 0) + for _, statement := range recorder.statements { + normalized := strings.ToUpper(strings.TrimSpace(statement)) + if strings.HasPrefix(normalized, "ALTER TABLE") || + strings.HasPrefix(normalized, "CREATE TABLE") || + strings.HasPrefix(normalized, "DROP TABLE") || + strings.HasPrefix(normalized, "RENAME TABLE") { + mutations = append(mutations, statement) + } + } + return mutations +} + +func TestUserSessionPreviousRefreshHashSchemaUsesNullableVarchar(t *testing.T) { + statement := &gorm.Statement{DB: DB} + require.NoError(t, statement.Parse(&UserSession{})) + field := statement.Schema.LookUpField("PreviousRefreshHash") + require.NotNil(t, field) + assert.Equal(t, "varchar(64)", field.TagSettings["TYPE"]) + assert.False(t, field.NotNull) +} + +func testPreviousRefreshHashMigration(t *testing.T, db *gorm.DB, recorder *migrationSQLRecorder, dialect string) { + t.Helper() + tableName := fmt.Sprintf("user_session_previous_hash_migration_%d", time.Now().UnixNano()) + t.Cleanup(func() { _ = db.Migrator().DropTable(tableName) }) + + require.NoError(t, db.Table(tableName).AutoMigrate(&previousRefreshHashMigrationLegacy{})) + digest := strings.Repeat("a", 60) + require.NoError(t, db.Table(tableName).Create(&previousRefreshHashMigrationLegacy{ + SID: "legacy-session", + PreviousRefreshHash: digest, + }).Error) + + require.NoError(t, db.Table(tableName).AutoMigrate(&previousRefreshHashMigrationTarget{})) + var session UserSession + require.NoError(t, db.Table(tableName). + Select("sid", "previous_refresh_hash"). + Where("sid = ?", "legacy-session"). + First(&session).Error) + assert.Equal(t, digest, session.PreviousRefreshHash, "legacy CHAR padding must be normalized on database reads") + + columnTypes, err := db.Table(tableName).Migrator().ColumnTypes(&previousRefreshHashMigrationTarget{}) + require.NoError(t, err) + var previousHashColumnFound bool + for _, columnType := range columnTypes { + if !strings.EqualFold(columnType.Name(), "previous_refresh_hash") { + continue + } + previousHashColumnFound = true + nullable, ok := columnType.Nullable() + require.True(t, ok) + if dialect != "sqlite" { + assert.True(t, nullable) + } + assert.Contains(t, strings.ToUpper(columnType.DatabaseTypeName()), "VARCHAR") + } + assert.True(t, previousHashColumnFound) + + recorder.reset() + require.NoError(t, db.Table(tableName).AutoMigrate(&previousRefreshHashMigrationTarget{})) + assert.Empty(t, recorder.schemaMutations(), "a second migration must not repeat type-changing DDL") +} + +func TestUserSessionPreviousRefreshHashMigrationSQLite(t *testing.T) { + recorder := &migrationSQLRecorder{} + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: recorder}) + require.NoError(t, err) + testPreviousRefreshHashMigration(t, db, recorder, "sqlite") +} + +func TestUserSessionPreviousRefreshHashMigrationConfiguredDatabases(t *testing.T) { + tests := []struct { + name string + env string + dialector func(string) gorm.Dialector + }{ + {name: "mysql", env: "TEST_MYSQL_DSN", dialector: func(dsn string) gorm.Dialector { return mysql.Open(dsn) }}, + {name: "postgres", env: "TEST_POSTGRES_DSN", dialector: func(dsn string) gorm.Dialector { + return postgres.New(postgres.Config{DSN: dsn, PreferSimpleProtocol: true}) + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dsn := strings.TrimSpace(os.Getenv(test.env)) + if dsn == "" { + t.Skip(test.env + " is not configured") + } + recorder := &migrationSQLRecorder{} + db, err := gorm.Open(test.dialector(dsn), &gorm.Config{Logger: recorder}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + t.Cleanup(func() { _ = sqlDB.Close() }) + testPreviousRefreshHashMigration(t, db, recorder, test.name) + }) + } +} diff --git a/model/user_session_test.go b/model/user_session_test.go index 25ad42e80f4f..e9e4e1b21db9 100644 --- a/model/user_session_test.go +++ b/model/user_session_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "testing" "time" @@ -271,6 +272,71 @@ func TestRotateUserSessionRefreshRaceAndReuse(t *testing.T) { assert.Equal(t, "refresh_reuse", stored.RevokedReason) } +func TestUserSessionPreviousRefreshHashNormalizesLegacyPadding(t *testing.T) { + setupUserSessionTest(t) + now := time.Now().Unix() + createUserSessionTestUser(t, 1010, 1) + digest := strings.Repeat("a", 64) + + blank := newTestUserSession("legacy-blank-previous-hash", 1010, now) + blank.PreviousRefreshHash = strings.Repeat(" ", 64) + blank.PreviousValidUntil = now + 60 + require.NoError(t, DB.Create(blank).Error) + loadedBlank, err := GetUserSessionBySID(blank.SID) + require.NoError(t, err) + assert.Empty(t, loadedBlank.PreviousRefreshHash) + + valid := newTestUserSession("legacy-valid-previous-hash", 1010, now) + valid.RefreshHash = strings.Repeat("b", 64) + valid.PreviousRefreshHash = digest + valid.PreviousValidUntil = now + 60 + require.NoError(t, DB.Create(valid).Error) + loadedValid, err := GetUserSessionBySID(valid.SID) + require.NoError(t, err) + assert.Equal(t, digest, loadedValid.PreviousRefreshHash) + + require.NoError(t, DB.Model(&UserSession{}).Where("sid = ?", valid.SID). + Updates(map[string]any{ + "previous_refresh_hash": digest + " ", + "previous_valid_until": now + 60, + }).Error) + _, err = RotateUserSessionRefresh(valid.UserID, valid.SID, digest, strings.Repeat("c", 64), now+1, 30*time.Second) + assert.ErrorIs(t, err, ErrUserSessionRefreshRace) + + revoked, err := RevokeUserSessionByRefreshHash(valid.SID, digest, "legacy-padded-refresh-logout") + require.NoError(t, err) + assert.True(t, revoked, "refresh-cookie logout must accept a legacy CHAR-padded previous digest inside its grace window") +} + +func TestUserSessionCacheExcludesRefreshDigests(t *testing.T) { + setupUserSessionTest(t) + useUserCacheMiniRedis(t) + now := time.Now().Unix() + session := newTestUserSession("cache-without-refresh-digests", 1011, now) + session.PreviousRefreshHash = strings.Repeat("a", 64) + session.PreviousValidUntil = now + 30 + require.NoError(t, writeUserSessionCache(session.cacheEntry(), userSessionCacheDeadline())) + + cacheKey := userSessionCacheKey(session.SID) + fields, err := common.RDB.HGetAll(context.Background(), cacheKey).Result() + require.NoError(t, err) + assert.NotContains(t, fields, "RefreshHash") + assert.NotContains(t, fields, "PreviousRefreshHash") + assert.NotContains(t, fields, "PreviousValidUntil") + + require.NoError(t, common.RDB.HSet(context.Background(), cacheKey, + "RefreshHash", strings.Repeat("b", 64), + "PreviousRefreshHash", strings.Repeat("c", 64)+" ", + "PreviousValidUntil", now+30, + ).Err()) + entry, err := getUserSessionCache(session.SID) + require.NoError(t, err) + cachedSession := entry.session() + assert.Empty(t, cachedSession.RefreshHash) + assert.Empty(t, cachedSession.PreviousRefreshHash) + assert.Zero(t, cachedSession.PreviousValidUntil) +} + func TestRevokeOtherUserSessionsKeepsCurrent(t *testing.T) { setupUserSessionTest(t) now := time.Now().Unix() diff --git a/web/default/src/components/sign-out-dialog.tsx b/web/default/src/components/sign-out-dialog.tsx index 185d33bbbfb9..9479c9c004f0 100644 --- a/web/default/src/components/sign-out-dialog.tsx +++ b/web/default/src/components/sign-out-dialog.tsx @@ -16,13 +16,15 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ConfirmDialog } from '@/components/confirm-dialog' import { logout } from '@/features/auth/api' -import { clearAuthentication } from '@/lib/auth-session' +import { clearAuthenticatedClientState } from '@/lib/auth-session' interface SignOutDialogProps { open: boolean @@ -31,6 +33,8 @@ interface SignOutDialogProps { export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) { const { t } = useTranslation() + const navigate = useNavigate() + const queryClient = useQueryClient() const [isSigningOut, setIsSigningOut] = useState(false) const handleSignOut = async () => { @@ -42,11 +46,9 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) { return } - clearAuthentication() + clearAuthenticatedClientState(queryClient) toast.success(t('Signed out')) - if (typeof window !== 'undefined') { - window.location.replace('/sign-in') - } + void navigate({ to: '/sign-in', replace: true }) } catch (error: unknown) { toast.error( error instanceof Error ? error.message : t('Failed to sign out session') diff --git a/web/default/src/features/auth/hooks/use-auth-redirect.ts b/web/default/src/features/auth/hooks/use-auth-redirect.ts index 940eedf3823d..19b09792df8c 100644 --- a/web/default/src/features/auth/hooks/use-auth-redirect.ts +++ b/web/default/src/features/auth/hooks/use-auth-redirect.ts @@ -19,25 +19,12 @@ For commercial licensing, please contact support@quantumnous.com import { useNavigate } from '@tanstack/react-router' import i18n from 'i18next' +import { + getSavedLanguage, + sanitizeAuthRedirect, +} from '@/features/auth/lib/auth-redirect' import { applyAuthBundle } from '@/lib/api' -import type { AuthBundle, AuthUser } from '@/stores/auth-store' - -function getSavedLanguage(user: AuthUser): string | undefined { - if (typeof user.language === 'string') { - return user.language - } - - if (typeof user.setting !== 'string') { - return undefined - } - - try { - const setting = JSON.parse(user.setting) as { language?: unknown } - return typeof setting.language === 'string' ? setting.language : undefined - } catch { - return undefined - } -} +import type { AuthBundle } from '@/stores/auth-store' /** * Hook for handling authentication redirects and user data management @@ -60,9 +47,9 @@ export function useAuthRedirect() { await i18n.changeLanguage(savedLang) } - // Navigate to target page - const targetPath = redirectTo || '/dashboard' - navigate({ to: targetPath, replace: true }) + const targetPath = + sanitizeAuthRedirect(redirectTo, window.location.origin) ?? '/dashboard' + navigate({ href: targetPath, replace: true }) } /** diff --git a/web/default/src/features/auth/lib/auth-redirect.test.ts b/web/default/src/features/auth/lib/auth-redirect.test.ts new file mode 100644 index 000000000000..f043c26bc0a0 --- /dev/null +++ b/web/default/src/features/auth/lib/auth-redirect.test.ts @@ -0,0 +1,98 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import type { AuthUser } from '@/stores/auth-store' + +import { getSavedLanguage, sanitizeAuthRedirect } from './auth-redirect' + +const origin = 'https://dashboard.example.com' + +describe('authentication redirect validation', () => { + test('preserves safe internal paths, search parameters, and fragments', () => { + assert.equal( + sanitizeAuthRedirect('/console?tab=usage#recent', origin), + '/console?tab=usage#recent' + ) + assert.equal( + sanitizeAuthRedirect( + 'https://dashboard.example.com/dashboard?tab=quota#daily', + origin + ), + '/dashboard?tab=quota#daily' + ) + }) + + test('rejects external and ambiguously parsed redirect targets', () => { + const unsafeTargets: unknown[] = [ + undefined, + '', + 'dashboard', + '//attacker.example/path', + 'https://attacker.example/path', + 'javascript:alert(1)', + '/\\attacker.example/path', + 'https:\\attacker.example/path', + ] + + for (const target of unsafeTargets) { + assert.equal(sanitizeAuthRedirect(target, origin), null) + } + }) + + test('rejects invalid or non-HTTP application origins', () => { + assert.equal(sanitizeAuthRedirect('/dashboard', 'not-an-origin'), null) + assert.equal(sanitizeAuthRedirect('/dashboard', 'file:///tmp/app'), null) + }) +}) + +describe('saved authentication language', () => { + const user: AuthUser = { id: 1, username: 'user', role: 1 } + + test('prefers the explicit user language', () => { + assert.equal( + getSavedLanguage({ + ...user, + language: 'ja', + setting: { language: 'fr' }, + }), + 'ja' + ) + }) + + test('reads object and JSON string settings', () => { + assert.equal( + getSavedLanguage({ ...user, setting: { language: 'fr' } }), + 'fr' + ) + assert.equal( + getSavedLanguage({ ...user, setting: '{"language":"ru"}' }), + 'ru' + ) + }) + + test('ignores malformed and non-string setting languages', () => { + assert.equal(getSavedLanguage({ ...user, setting: '{' }), undefined) + assert.equal( + getSavedLanguage({ ...user, setting: { language: 123 } }), + undefined + ) + }) +}) diff --git a/web/default/src/features/auth/lib/auth-redirect.ts b/web/default/src/features/auth/lib/auth-redirect.ts new file mode 100644 index 000000000000..ea93d3beb9cb --- /dev/null +++ b/web/default/src/features/auth/lib/auth-redirect.ts @@ -0,0 +1,80 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { AuthUser } from '@/stores/auth-store' + +const allowedRedirectProtocols = new Set(['http:', 'https:']) + +export function getSavedLanguage(user: AuthUser): string | undefined { + if (typeof user.language === 'string') { + return user.language + } + + if (user.setting && typeof user.setting === 'object') { + return typeof user.setting.language === 'string' + ? user.setting.language + : undefined + } + + if (typeof user.setting !== 'string') { + return undefined + } + + try { + const setting = JSON.parse(user.setting) as { language?: unknown } + return typeof setting.language === 'string' ? setting.language : undefined + } catch { + return undefined + } +} + +export function sanitizeAuthRedirect( + value: unknown, + origin: string +): string | null { + if (typeof value !== 'string') return null + + const target = value.trim() + if (!target || target.includes('\\') || target.startsWith('//')) return null + + let trustedOrigin: URL + try { + trustedOrigin = new URL(origin) + } catch { + return null + } + if (!allowedRedirectProtocols.has(trustedOrigin.protocol)) return null + + let redirectURL: URL + try { + redirectURL = target.startsWith('/') + ? new URL(target, trustedOrigin.origin) + : new URL(target) + } catch { + return null + } + + if ( + !allowedRedirectProtocols.has(redirectURL.protocol) || + redirectURL.origin !== trustedOrigin.origin + ) { + return null + } + + return `${redirectURL.pathname}${redirectURL.search}${redirectURL.hash}` +} diff --git a/web/default/src/features/auth/lib/oauth-bind-window.test.ts b/web/default/src/features/auth/lib/oauth-bind-window.test.ts index b63ee3a45d18..3ef4ba432262 100644 --- a/web/default/src/features/auth/lib/oauth-bind-window.test.ts +++ b/web/default/src/features/auth/lib/oauth-bind-window.test.ts @@ -20,6 +20,8 @@ import assert from 'node:assert/strict' import { describe, test } from 'node:test' import { + parseTelegramBindCallback, + postTelegramBindResult, startOAuthBindResponseDeadline, watchOAuthPopupClosed, } from './oauth-bind-window' @@ -48,6 +50,103 @@ function fakeTimerRuntime() { } describe('OAuth bind popup lifecycle', () => { + test('parses Telegram success and stable error callbacks', () => { + assert.deepEqual( + parseTelegramBindCallback({ + telegram_bind: 'success', + flow_token: 'flow-success', + }), + { + kind: 'result', + flowToken: 'flow-success', + success: true, + } + ) + assert.deepEqual( + parseTelegramBindCallback({ + telegram_bind: 'error', + flow_token: 'flow-error', + error_code: 'TELEGRAM_BIND_ALREADY_BOUND', + }), + { + kind: 'result', + flowToken: 'flow-error', + success: false, + code: 'TELEGRAM_BIND_ALREADY_BOUND', + } + ) + }) + + test('rejects Telegram callbacks without a flow token and ignores descriptions', () => { + assert.deepEqual(parseTelegramBindCallback({ telegram_bind: 'error' }), { + kind: 'invalid', + }) + assert.deepEqual( + parseTelegramBindCallback({ + telegram_bind: 'error', + flow_token: 'flow-error', + error_code: 'UNKNOWN_CODE', + error_description: 'untrusted message', + } as Parameters[0]), + { + kind: 'result', + flowToken: 'flow-error', + success: false, + code: 'UNKNOWN_CODE', + } + ) + assert.equal(parseTelegramBindCallback({}), null) + }) + + test('posts only complete Telegram bind results to an available opener', () => { + const messages: Array<{ message: unknown; targetOrigin: string }> = [] + const opener = { + closed: false, + postMessage: (message: unknown, targetOrigin: string) => { + messages.push({ message, targetOrigin }) + }, + } as Pick + const callback = parseTelegramBindCallback({ + telegram_bind: 'error', + flow_token: 'flow-error', + error_code: 'UNKNOWN_CODE', + }) + + assert.equal( + postTelegramBindResult(callback, opener, 'https://dashboard.example.com'), + true + ) + assert.deepEqual(messages, [ + { + message: { + type: 'telegram:binding:result', + flow_token: 'flow-error', + success: false, + code: 'UNKNOWN_CODE', + }, + targetOrigin: 'https://dashboard.example.com', + }, + ]) + + assert.equal( + postTelegramBindResult( + { kind: 'invalid' }, + opener, + 'https://example.com' + ), + false + ) + assert.equal( + postTelegramBindResult( + callback, + { ...opener, closed: true }, + 'https://example.com' + ), + false + ) + assert.equal(messages.length, 1) + }) + test('waits 30 seconds for the opener response and can be cancelled', () => { const timer = fakeTimerRuntime() let timedOut = false diff --git a/web/default/src/features/auth/lib/oauth-bind-window.ts b/web/default/src/features/auth/lib/oauth-bind-window.ts index 605adf3d2e1e..f9d3658c122f 100644 --- a/web/default/src/features/auth/lib/oauth-bind-window.ts +++ b/web/default/src/features/auth/lib/oauth-bind-window.ts @@ -16,6 +16,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { TELEGRAM_BIND_RESULT_MESSAGE } from '@/features/auth/constants' + interface TimerRuntime { schedule: (callback: () => void, delay: number) => unknown cancel: (handle: unknown) => void @@ -35,6 +37,64 @@ const intervalRuntime: TimerRuntime = { ), } +interface TelegramBindCallbackSearch { + telegram_bind?: string + flow_token?: string + error_code?: string +} + +export type TelegramBindCallback = + | { + kind: 'result' + flowToken: string + success: boolean + code?: string + } + | { kind: 'invalid' } + | null + +export function parseTelegramBindCallback( + search: TelegramBindCallbackSearch +): TelegramBindCallback { + if (search.telegram_bind !== 'success' && search.telegram_bind !== 'error') { + return null + } + if (!search.flow_token) return { kind: 'invalid' } + + if (search.telegram_bind === 'success') { + return { + kind: 'result', + flowToken: search.flow_token, + success: true, + } + } + return { + kind: 'result', + flowToken: search.flow_token, + success: false, + code: search.error_code, + } +} + +export function postTelegramBindResult( + callback: TelegramBindCallback, + opener: Pick | null, + targetOrigin: string +): boolean { + if (callback?.kind !== 'result' || !opener || opener.closed) return false + + opener.postMessage( + { + type: TELEGRAM_BIND_RESULT_MESSAGE, + flow_token: callback.flowToken, + success: callback.success, + code: callback.code, + }, + targetOrigin + ) + return true +} + export function startOAuthBindResponseDeadline( onTimeout: () => void, delay = 30_000, diff --git a/web/default/src/features/auth/secure-verification/api.ts b/web/default/src/features/auth/secure-verification/api.ts index 6b972027b2a0..8eab7e9ec765 100644 --- a/web/default/src/features/auth/secure-verification/api.ts +++ b/web/default/src/features/auth/secure-verification/api.ts @@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import i18next from 'i18next' +import type { ApiResponse } from '@/features/auth/types' import { api, get2FAStatus } from '@/lib/api' import { buildAssertionResult, @@ -105,7 +106,7 @@ async function verifyTwoFA( ) } - const res = await api.post('/api/verify', { + const res = await api.post>('/api/verify', { method: '2fa', code: trimmed, scope, @@ -117,7 +118,7 @@ async function verifyTwoFA( if (!res.data.data?.proof_token) { throw new Error(i18next.t('Verification proof was not returned')) } - return res.data.data as SecurityProof + return res.data.data } /** diff --git a/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts b/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts index ed244483b8a1..bb274c962058 100644 --- a/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts +++ b/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts @@ -149,7 +149,7 @@ export function useSecureVerification( try { if (!state.scope) { - throw new Error('Verification scope is missing') + throw new Error(i18next.t('Verification scope is missing')) } const proof = await verify( actualMethod, diff --git a/web/default/src/features/auth/sign-in/components/user-auth-form.tsx b/web/default/src/features/auth/sign-in/components/user-auth-form.tsx index 65fa02f02023..56ba39799d0d 100644 --- a/web/default/src/features/auth/sign-in/components/user-auth-form.tsx +++ b/web/default/src/features/auth/sign-in/components/user-auth-form.tsx @@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { zodResolver } from '@hookform/resolvers/zod' import { Link } from '@tanstack/react-router' +import axios from 'axios' import { Loader2, LogIn, KeyRound } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' @@ -181,8 +182,9 @@ export function UserAuthForm({ await handleLoginSuccess(res.data, redirectTo) toast.success(t('Welcome back!')) } - } catch { - // Errors are handled by global interceptor + } catch (error: unknown) { + if (axios.isAxiosError(error)) return + toast.error(error instanceof Error ? error.message : loginFailedMessage) } finally { setIsLoading(false) } diff --git a/web/default/src/features/profile/components/dialogs/telegram-bind-dialog.tsx b/web/default/src/features/profile/components/dialogs/telegram-bind-dialog.tsx index 0c9dd6bfcc2c..4e1842896ffe 100644 --- a/web/default/src/features/profile/components/dialogs/telegram-bind-dialog.tsx +++ b/web/default/src/features/profile/components/dialogs/telegram-bind-dialog.tsx @@ -26,6 +26,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { TELEGRAM_BIND_RESULT_MESSAGE } from '@/features/auth/constants' +import { getServerErrorMessageKey } from '@/lib/server-error-message' import { startTelegramBind } from '../../api' @@ -97,7 +98,7 @@ export function TelegramBindDialog({ type?: string flow_token?: string success?: boolean - message?: string + code?: string } | null if ( !result || @@ -107,7 +108,8 @@ export function TelegramBindDialog({ return } if (!result.success) { - setError(result.message || t('Failed to start Telegram binding')) + const messageKey = getServerErrorMessageKey({ code: result.code }) + setError(t(messageKey || 'Telegram binding failed. Please try again.')) return } toast.success(t('Binding successful!')) diff --git a/web/default/src/features/profile/components/login-sessions-card.tsx b/web/default/src/features/profile/components/login-sessions-card.tsx index 8af2b5f0dcca..246875be9661 100644 --- a/web/default/src/features/profile/components/login-sessions-card.tsx +++ b/web/default/src/features/profile/components/login-sessions-card.tsx @@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { Logout01Icon, SmartPhone01Icon } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' import { useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -41,7 +42,7 @@ import { } from '@/components/ui/empty' import { Separator } from '@/components/ui/separator' import { Skeleton } from '@/components/ui/skeleton' -import { clearAuthentication } from '@/lib/api' +import { clearAuthenticatedClientState } from '@/lib/api' import type { LoginSession } from '@/stores/auth-store' import { @@ -56,6 +57,7 @@ const sessionQueryKey = ['profile', 'login-sessions'] as const export function LoginSessionsCard() { const { t } = useTranslation() + const navigate = useNavigate() const queryClient = useQueryClient() const [revokeTarget, setRevokeTarget] = useState(null) const [confirmOthers, setConfirmOthers] = useState(false) @@ -85,8 +87,8 @@ export function LoginSessionsCard() { ) setRevokeTarget(null) if (revokedCurrent) { - clearAuthentication() - window.location.replace('/sign-in') + clearAuthenticatedClientState(queryClient) + void navigate({ to: '/sign-in', replace: true }) return } toast.success(t('Session signed out')) diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index bfd1db2c592b..7f566f870eba 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -4432,6 +4432,8 @@ "Team Collaboration": "Team Collaboration", "Technical Support": "Technical Support", "Telegram": "Telegram", + "Telegram binding failed. Please try again.": "Telegram binding failed. Please try again.", + "Telegram binding is disabled.": "Telegram binding is disabled.", "Telegram login requires widget integration; coming soon": "Telegram login requires widget integration; coming soon", "Telegram Login Widget": "Telegram Login Widget", "Temperature": "Temperature", @@ -4485,6 +4487,7 @@ "The exact model identifier as used in API requests.": "The exact model identifier as used in API requests.", "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.", "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:", + "The login session that started this Telegram binding is no longer valid.": "The login session that started this Telegram binding is no longer valid.", "The mapped upstream model(s)": "The mapped upstream model(s)", "The model that was requested": "The model that was requested", "The model you're looking for doesn't exist.": "The model you're looking for doesn't exist.", @@ -4496,6 +4499,7 @@ "The site is not available at the moment.": "The site is not available at the moment.", "The slug is appended to the URL:": "The slug is appended to the URL:", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.", + "The Telegram authorization request is invalid or expired.": "The Telegram authorization request is invalid or expired.", "The token group that will have a custom ratio": "The token group that will have a custom ratio", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "The token has no group, so it is billed as the user group vip, using the base ratio of vip.", "The two roles of a group": "The two roles of a group", @@ -4554,9 +4558,13 @@ "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.", "This session will lose access immediately and must sign in again.": "This session will lose access immediately and must sign in again.", "This site currently has {{count}} models enabled": "This site currently has {{count}} models enabled", + "This Telegram account is already bound.": "This Telegram account is already bound.", + "This Telegram binding request has expired or has already been used.": "This Telegram binding request has expired or has already been used.", "This tier catches any request that did not match earlier tiers.": "This tier catches any request that did not match earlier tiers.", "this token group": "this token group", "This Uptime Kuma group will be removed from the list.": "This Uptime Kuma group will be removed from the list.", + "This user account is disabled.": "This user account is disabled.", + "This user account no longer exists.": "This user account no longer exists.", "this user group": "this user group", "This user has no bindings": "This user has no bindings", "This week": "This week", @@ -5011,6 +5019,7 @@ "Verification is not configured properly": "Verification is not configured properly", "Verification proof was not returned": "Verification proof was not returned", "Verification required to reveal the saved key.": "Verification required to reveal the saved key.", + "Verification scope is missing": "Verification scope is missing", "Verify": "Verify", "Verify and Sign In": "Verify and Sign In", "Verify routing with Playground or your client": "Verify routing with Playground or your client", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index bc49d2b1869c..036c5de1c8f1 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -4432,6 +4432,8 @@ "Team Collaboration": "Collaboration d'équipe", "Technical Support": "Support technique", "Telegram": "Telegram", + "Telegram binding failed. Please try again.": "Échec de la liaison Telegram. Veuillez réessayer.", + "Telegram binding is disabled.": "La liaison Telegram est désactivée.", "Telegram login requires widget integration; coming soon": "La connexion Telegram nécessite l'intégration d'un widget ; disponible bientôt", "Telegram Login Widget": "Widget de connexion Telegram", "Temperature": "Température", @@ -4485,6 +4487,7 @@ "The exact model identifier as used in API requests.": "L'identifiant exact du modèle tel qu'utilisé dans les requêtes API.", "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "Les modèles suivants présentent des conflits de type de facturation (prix fixe vs facturation au ratio). Confirmez pour procéder aux changements.", "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "Les modèles suivants dans la redirection du modèle n'ont pas été ajoutés à la liste \"Modèles\" et peuvent échouer lors de l'invocation en raison de modèles disponibles manquants :", + "The login session that started this Telegram binding is no longer valid.": "La session de connexion ayant lancé cette liaison Telegram n’est plus valide.", "The mapped upstream model(s)": "Le(s) modèle(s) amont mappé(s)", "The model that was requested": "Le modèle qui a été demandé", "The model you're looking for doesn't exist.": "Le modèle que vous recherchez n'existe pas.", @@ -4496,6 +4499,7 @@ "The site is not available at the moment.": "Le site n'est pas disponible pour le moment.", "The slug is appended to the URL:": "Le slug est ajouté à l'URL :", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "La synchronisation récupérera les modèles et fournisseurs manquants à partir de la source sélectionnée. Les enregistrements existants ne sont mis à jour que lorsque vous approuvez les conflits.", + "The Telegram authorization request is invalid or expired.": "La demande d’autorisation Telegram est invalide ou a expiré.", "The token group that will have a custom ratio": "Le groupe de jetons qui aura un ratio personnalisé", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "Le jeton n’a pas de groupe, il est donc facturé sous le groupe d’utilisateurs vip, avec le taux de base de vip.", "The two roles of a group": "Les deux rôles d’un groupe", @@ -4552,11 +4556,15 @@ "This project must be used in compliance with the": "Ce projet doit être utilisé conformément aux", "This removes {{count}} failed models from this channel. This action cannot be undone.": "Cela supprime {{count}} modèles en échec de ce canal. Cette action est irréversible.", "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Cette route découvre les modèles OpenAI en amont et ne peut être ni divisée ni associée par des règles de modèles clients.", - "This session will lose access immediately and must sign in again.": "Cette session perdra immédiatement l’accès et devra se reconnecter.", + "This session will lose access immediately and must sign in again.": "Cette session perdra immédiatement l’accès ; vous devrez vous reconnecter.", "This site currently has {{count}} models enabled": "Ce site compte actuellement {{count}} modèles activés", + "This Telegram account is already bound.": "Ce compte Telegram est déjà lié.", + "This Telegram binding request has expired or has already been used.": "Cette demande de liaison Telegram a expiré ou a déjà été utilisée.", "This tier catches any request that did not match earlier tiers.": "Ce palier récupère toute requête qui ne correspond à aucun palier précédent.", "this token group": "ce groupe de jetons", "This Uptime Kuma group will be removed from the list.": "Ce groupe Uptime Kuma sera retiré de la liste.", + "This user account is disabled.": "Ce compte utilisateur est désactivé.", + "This user account no longer exists.": "Ce compte utilisateur n’existe plus.", "this user group": "ce groupe d'utilisateurs", "This user has no bindings": "Cet utilisateur n'a aucune liaison", "This week": "Cette semaine", @@ -5011,6 +5019,7 @@ "Verification is not configured properly": "La vérification n'est pas configurée correctement", "Verification proof was not returned": "La preuve de vérification n’a pas été renvoyée", "Verification required to reveal the saved key.": "Vérification requise pour révéler la clé enregistrée.", + "Verification scope is missing": "La portée de vérification est manquante", "Verify": "Vérifier", "Verify and Sign In": "Vérifier et se connecter", "Verify routing with Playground or your client": "Vérifiez le routage avec Playground ou votre client", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index a8976785e72b..1688afba027d 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -4432,6 +4432,8 @@ "Team Collaboration": "チームコラボレーション", "Technical Support": "テクニカルサポート", "Telegram": "Telegram", + "Telegram binding failed. Please try again.": "Telegram の連携に失敗しました。もう一度お試しください。", + "Telegram binding is disabled.": "Telegram 連携は無効です。", "Telegram login requires widget integration; coming soon": "Telegramログインにはウィジェット統合が必要です;近日公開", "Telegram Login Widget": "Telegramログインウィジェット", "Temperature": "温度", @@ -4485,6 +4487,7 @@ "The exact model identifier as used in API requests.": "APIリクエストで使用される正確なモデル識別子。", "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "以下のモデルには請求タイプ(固定価格 vs 比率請求)の競合があります。変更を続行するには確認してください。", "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "モデルリダイレクト内の以下のモデルは\"モデル\"リストに追加されていないため、利用可能なモデルが不足して呼び出しが失敗する可能性があります:", + "The login session that started this Telegram binding is no longer valid.": "この Telegram 連携を開始したログインセッションは無効になりました。", "The mapped upstream model(s)": "マッピングされたアップストリームモデル", "The model that was requested": "リクエストされたモデル", "The model you're looking for doesn't exist.": "お探しのモデルは存在しません。", @@ -4496,6 +4499,7 @@ "The site is not available at the moment.": "現在、このサイトは利用できません。", "The slug is appended to the URL:": "スラッグがURLに追加されます:", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "同期により、選択されたソースから不足しているモデルとベンダーが取得されます。既存のレコードは、競合を承認した場合にのみ更新されます。", + "The Telegram authorization request is invalid or expired.": "Telegram の認証リクエストが無効か、有効期限が切れています。", "The token group that will have a custom ratio": "カスタム比率を持つトークングループ", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "トークンにグループがないため、ユーザーグループ vip として課金され、vip の基本倍率が使われます。", "The two roles of a group": "グループの2つの役割", @@ -4554,9 +4558,13 @@ "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "このルートはアップストリームの OpenAI モデルを検出するためのもので、分割やクライアントモデルルールによる照合はできません。", "This session will lose access immediately and must sign in again.": "このセッションは直ちにアクセスできなくなり、再度サインインが必要になります。", "This site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です", + "This Telegram account is already bound.": "この Telegram アカウントはすでに連携されています。", + "This Telegram binding request has expired or has already been used.": "この Telegram 連携リクエストは期限切れか、すでに使用されています。", "This tier catches any request that did not match earlier tiers.": "この段階は、前の段階に一致しなかったすべてのリクエストを受け取ります。", "this token group": "このトークングループ", "This Uptime Kuma group will be removed from the list.": "この Uptime Kuma グループはリストから削除されます。", + "This user account is disabled.": "このユーザーアカウントは無効です。", + "This user account no longer exists.": "このユーザーアカウントは存在しません。", "this user group": "このユーザーグループ", "This user has no bindings": "このユーザーには連携がありません", "This week": "今週", @@ -5011,6 +5019,7 @@ "Verification is not configured properly": "認証が正しく設定されていません", "Verification proof was not returned": "認証証明が返されませんでした", "Verification required to reveal the saved key.": "保存されたキーを表示するには、認証が必要です。", + "Verification scope is missing": "検証スコープがありません", "Verify": "認証", "Verify and Sign In": "確認してサインイン", "Verify routing with Playground or your client": "Playground またはクライアントでルーティングを確認", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index b0c7a554a802..14085f6c3bcc 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -3872,7 +3872,7 @@ "Reveal key": "Показать ключ", "Revenue": "Доход", "Review & initialize": "Проверить и инициализировать", - "Review and sign out devices currently using your account.": "Просмотрите и отключите устройства, которые сейчас используют вашу учётную запись.", + "Review and sign out devices currently using your account.": "Просмотрите устройства, использующие вашу учётную запись, и завершите их сеансы.", "Review model rates before scaling traffic": "Проверьте тарифы моделей перед масштабированием трафика", "Review your payment details": "Проверьте свои платежные данные", "Review your purchase details before proceeding.": "Просмотрите детали покупки перед продолжением.", @@ -4432,6 +4432,8 @@ "Team Collaboration": "Совместная работа в команде", "Technical Support": "Техническая поддержка", "Telegram": "Telegram", + "Telegram binding failed. Please try again.": "Не удалось привязать Telegram. Повторите попытку.", + "Telegram binding is disabled.": "Привязка Telegram отключена.", "Telegram login requires widget integration; coming soon": "Вход через Telegram требует интеграции виджета; скоро", "Telegram Login Widget": "Виджет входа Telegram", "Temperature": "Температура", @@ -4485,6 +4487,7 @@ "The exact model identifier as used in API requests.": "Точный идентификатор модели, используемый в запросах API.", "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "Следующие модели имеют конфликты типов тарификации (фиксированная цена против тарификации по соотношению). Подтвердите, чтобы продолжить изменения.", "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "Следующие модели в перенаправлении модели не были добавлены в список \"Модели\" и могут не работать при вызове из-за отсутствия доступных моделей:", + "The login session that started this Telegram binding is no longer valid.": "Сеанс входа, из которого была начата привязка Telegram, больше недействителен.", "The mapped upstream model(s)": "Сопоставленные upstream модель(и)", "The model that was requested": "Запрошенная модель", "The model you're looking for doesn't exist.": "Модель, которую вы ищете, не существует.", @@ -4496,6 +4499,7 @@ "The site is not available at the moment.": "Сайт в данный момент недоступен.", "The slug is appended to the URL:": "Слаг добавляется к URL:", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "Синхронизация получит отсутствующие модели и поставщиков из выбранного источника. Существующие записи обновляются только после подтверждения конфликтов.", + "The Telegram authorization request is invalid or expired.": "Запрос авторизации Telegram недействителен или истёк.", "The token group that will have a custom ratio": "Группа токенов, которая будет иметь пользовательское соотношение", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "У токена нет группы, поэтому вызов тарифицируется по группе пользователя vip с базовым коэффициентом vip.", "The two roles of a group": "Две роли группы", @@ -4554,9 +4558,13 @@ "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Этот маршрут обнаруживает модели OpenAI вышестоящего сервиса; его нельзя разделять или сопоставлять по правилам клиентских моделей.", "This session will lose access immediately and must sign in again.": "Этот сеанс немедленно потеряет доступ, и потребуется повторный вход.", "This site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}", + "This Telegram account is already bound.": "Эта учётная запись Telegram уже привязана.", + "This Telegram binding request has expired or has already been used.": "Этот запрос на привязку Telegram истёк или уже был использован.", "This tier catches any request that did not match earlier tiers.": "Этот уровень обрабатывает все запросы, которые не совпали с предыдущими уровнями.", "this token group": "эта группа токенов", "This Uptime Kuma group will be removed from the list.": "Эта группа Uptime Kuma будет удалена из списка.", + "This user account is disabled.": "Эта учётная запись пользователя отключена.", + "This user account no longer exists.": "Эта учётная запись пользователя больше не существует.", "this user group": "эта группа пользователей", "This user has no bindings": "У этого пользователя нет привязок", "This week": "На этой неделе", @@ -5011,6 +5019,7 @@ "Verification is not configured properly": "Подтверждение настроено неправильно", "Verification proof was not returned": "Подтверждение проверки не было получено", "Verification required to reveal the saved key.": "Требуется подтверждение для отображения сохраненного ключа.", + "Verification scope is missing": "Область проверки не указана", "Verify": "Проверить", "Verify and Sign In": "Подтвердить и войти", "Verify routing with Playground or your client": "Проверьте маршрутизацию через Playground или ваш клиент", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index a58bcc7b761b..4bd0018468cd 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -4432,6 +4432,8 @@ "Team Collaboration": "Teamwork", "Technical Support": "Hỗ trợ kỹ thuật", "Telegram": "Telegram", + "Telegram binding failed. Please try again.": "Liên kết Telegram không thành công. Vui lòng thử lại.", + "Telegram binding is disabled.": "Tính năng liên kết Telegram đã bị tắt.", "Telegram login requires widget integration; coming soon": "Đăng nhập Telegram yêu cầu tích hợp widget; sắp ra mắt", "Telegram Login Widget": "Tiện ích đăng nhập Telegram", "Temperature": "Nhiệt độ", @@ -4485,6 +4487,7 @@ "The exact model identifier as used in API requests.": "Mã định danh mô hình chính xác như được sử dụng trong các yêu cầu API.", "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "Các mô hình sau có xung đột loại thanh toán (giá cố định so với thanh toán theo tỷ lệ). Xác nhận để tiếp tục với các thay đổi.", "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "Các mô hình sau trong chuyển hướng mô hình chưa được thêm vào danh sách \"Mô hình\" và có thể gọi thất bại do thiếu các mô hình có sẵn:", + "The login session that started this Telegram binding is no longer valid.": "Phiên đăng nhập đã bắt đầu liên kết Telegram này không còn hợp lệ.", "The mapped upstream model(s)": "Mô hình(s) thượng nguồn được ánh xạ", "The model that was requested": "Mô hình đã được yêu cầu", "The model you're looking for doesn't exist.": "Mô hình bạn đang tìm kiếm không tồn tại.", @@ -4496,6 +4499,7 @@ "The site is not available at the moment.": "Trang web hiện không khả dụng.", "The slug is appended to the URL:": "Slug được gắn vào URL:", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "Đồng bộ hóa sẽ tìm nạp các mẫu và nhà cung cấp còn thiếu từ nguồn đã chọn. Các bản ghi hiện có chỉ được cập nhật khi bạn chấp thuận các xung đột.", + "The Telegram authorization request is invalid or expired.": "Yêu cầu xác thực Telegram không hợp lệ hoặc đã hết hạn.", "The token group that will have a custom ratio": "The token group will have a custom ratio.", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "Token không có nhóm, nên được tính phí theo nhóm người dùng vip, dùng hệ số cơ bản của vip.", "The two roles of a group": "Hai vai trò của một nhóm", @@ -4554,9 +4558,13 @@ "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Tuyến này khám phá các mô hình OpenAI thượng nguồn và không thể tách hoặc đối sánh bằng quy tắc mô hình phía máy khách.", "This session will lose access immediately and must sign in again.": "Phiên này sẽ mất quyền truy cập ngay lập tức và phải đăng nhập lại.", "This site currently has {{count}} models enabled": "Trang này hiện đã bật {{count}} mô hình", + "This Telegram account is already bound.": "Tài khoản Telegram này đã được liên kết.", + "This Telegram binding request has expired or has already been used.": "Yêu cầu liên kết Telegram này đã hết hạn hoặc đã được sử dụng.", "This tier catches any request that did not match earlier tiers.": "Tầng này bắt mọi yêu cầu không khớp với các tầng trước.", "this token group": "nhóm token này", "This Uptime Kuma group will be removed from the list.": "Nhóm Uptime Kuma này sẽ bị xóa khỏi danh sách.", + "This user account is disabled.": "Tài khoản người dùng này đã bị vô hiệu hóa.", + "This user account no longer exists.": "Tài khoản người dùng này không còn tồn tại.", "this user group": "nhóm người dùng này", "This user has no bindings": "Người dùng này không có liên kết nào", "This week": "Tuần này", @@ -5011,6 +5019,7 @@ "Verification is not configured properly": "Xác thực chưa được cấu hình đúng cách", "Verification proof was not returned": "Không nhận được bằng chứng xác minh", "Verification required to reveal the saved key.": "Yêu cầu xác minh để tiết lộ khóa đã lưu.", + "Verification scope is missing": "Thiếu phạm vi xác minh", "Verify": "Kiểm tra", "Verify and Sign In": "Xác minh và Đăng nhập", "Verify routing with Playground or your client": "Xác minh định tuyến bằng Playground hoặc client của bạn", diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index f563d6e72446..b82a2b2307f4 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -4432,6 +4432,8 @@ "Team Collaboration": "團隊協作", "Technical Support": "技術支援", "Telegram": "Telegram", + "Telegram binding failed. Please try again.": "Telegram 綁定失敗,請再試一次。", + "Telegram binding is disabled.": "Telegram 綁定已停用。", "Telegram login requires widget integration; coming soon": "Telegram 登入需要小部件整合;即將推出", "Telegram Login Widget": "Telegram 登入小部件", "Temperature": "溫度", @@ -4485,6 +4487,7 @@ "The exact model identifier as used in API requests.": "API 請求中使用的確切模型標識符。", "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "以下模型存在收費類型衝突(固定價格 vs 比例收費)。確認以繼續更改。", "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "模型重新導向裡的下列模型尚未新增到「模型」列表,呼叫時會因為缺少可用模型而失敗:", + "The login session that started this Telegram binding is no longer valid.": "發起此 Telegram 綁定的登入工作階段已失效。", "The mapped upstream model(s)": "映射的上游模型", "The model that was requested": "被請求的模型", "The model you're looking for doesn't exist.": "您查找的模型不存在。", @@ -4496,6 +4499,7 @@ "The site is not available at the moment.": "該站點目前不可用。", "The slug is appended to the URL:": "別名將附加到 URL:", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "同步將從選定的源獲取缺失的模型和供應商。僅在您批准衝突時才會更新現有記錄。", + "The Telegram authorization request is invalid or expired.": "Telegram 授權要求無效或已過期。", "The token group that will have a custom ratio": "將具有自訂比例的令牌分組", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "令牌未設定分組,因此按用戶分組 vip 收費,使用 vip 的基礎倍率。", "The two roles of a group": "分組的兩種角色", @@ -4554,9 +4558,13 @@ "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "此路由用於探索上游 OpenAI 模型,無法拆分或使用用戶端模型規則配對。", "This session will lose access immediately and must sign in again.": "此工作階段將立即失去存取權限,且必須重新登入。", "This site currently has {{count}} models enabled": "本站目前已啟用模型,總計 {{count}} 個", + "This Telegram account is already bound.": "此 Telegram 帳號已綁定。", + "This Telegram binding request has expired or has already been used.": "此 Telegram 綁定要求已過期或已使用。", "This tier catches any request that did not match earlier tiers.": "此階梯會兜底處理未匹配前面階梯的請求。", "this token group": "此令牌分組", "This Uptime Kuma group will be removed from the list.": "此 Uptime Kuma 分組將從列表中移除。", + "This user account is disabled.": "此使用者帳號已停用。", + "This user account no longer exists.": "此使用者帳號已不存在。", "this user group": "此用戶分組", "This user has no bindings": "該用戶無任何連結", "This week": "本週", @@ -5011,6 +5019,7 @@ "Verification is not configured properly": "驗證未正確設定", "Verification proof was not returned": "伺服器未傳回驗證憑證", "Verification required to reveal the saved key.": "需要驗證才能顯示已儲存的金鑰。", + "Verification scope is missing": "缺少驗證範圍", "Verify": "驗證", "Verify and Sign In": "驗證並登入", "Verify routing with Playground or your client": "使用 Playground 或你的用戶端驗證路由", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index b533dc7929d9..d117749b6d6b 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -4432,6 +4432,8 @@ "Team Collaboration": "团队协作", "Technical Support": "技术支持", "Telegram": "Telegram", + "Telegram binding failed. Please try again.": "Telegram 绑定失败,请重试。", + "Telegram binding is disabled.": "Telegram 绑定已禁用。", "Telegram login requires widget integration; coming soon": "Telegram 登录需要小部件集成;即将推出", "Telegram Login Widget": "Telegram 登录小部件", "Temperature": "温度", @@ -4485,6 +4487,7 @@ "The exact model identifier as used in API requests.": "API 请求中使用的确切模型标识符。", "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "以下模型存在计费类型冲突(固定价格 vs 比例计费)。确认以继续更改。", "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "模型重定向里的下列模型尚未添加到\"模型\"列表,调用时会因为缺少可用模型而失败:", + "The login session that started this Telegram binding is no longer valid.": "发起此 Telegram 绑定的登录会话已失效。", "The mapped upstream model(s)": "映射的上游模型", "The model that was requested": "被请求的模型", "The model you're looking for doesn't exist.": "您查找的模型不存在。", @@ -4496,6 +4499,7 @@ "The site is not available at the moment.": "该站点目前不可用。", "The slug is appended to the URL:": "别名将附加到 URL:", "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "同步将从选定的源获取缺失的模型和供应商。仅在您批准冲突时才会更新现有记录。", + "The Telegram authorization request is invalid or expired.": "Telegram 授权请求无效或已过期。", "The token group that will have a custom ratio": "将具有自定义比例的令牌分组", "The token has no group, so it is billed as the user group vip, using the base ratio of vip.": "令牌未设置分组,因此按用户分组 vip 计费,使用 vip 的基础倍率。", "The two roles of a group": "分组的两种角色", @@ -4554,9 +4558,13 @@ "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "此路由用于发现上游 OpenAI 模型,不能拆分或使用客户端模型规则匹配。", "This session will lose access immediately and must sign in again.": "此会话将立即失去访问权限,并且必须重新登录。", "This site currently has {{count}} models enabled": "本站当前已启用模型,总计 {{count}} 个", + "This Telegram account is already bound.": "此 Telegram 账户已被绑定。", + "This Telegram binding request has expired or has already been used.": "此 Telegram 绑定请求已过期或已使用。", "This tier catches any request that did not match earlier tiers.": "此阶梯会兜底处理未匹配前面阶梯的请求。", "this token group": "此令牌分组", "This Uptime Kuma group will be removed from the list.": "此 Uptime Kuma 分组将从列表中移除。", + "This user account is disabled.": "此用户账户已被禁用。", + "This user account no longer exists.": "此用户账户已不存在。", "this user group": "此用户分组", "This user has no bindings": "该用户无任何绑定", "This week": "本周", @@ -5011,6 +5019,7 @@ "Verification is not configured properly": "验证未正确配置", "Verification proof was not returned": "服务端未返回验证凭证", "Verification required to reveal the saved key.": "需要验证才能显示已保存的密钥。", + "Verification scope is missing": "缺少验证范围", "Verify": "验证", "Verify and Sign In": "验证并登录", "Verify routing with Playground or your client": "使用 Playground 或你的客户端验证路由", diff --git a/web/default/src/i18n/static-keys.ts b/web/default/src/i18n/static-keys.ts index b57a2587ca9a..a435b0471155 100644 --- a/web/default/src/i18n/static-keys.ts +++ b/web/default/src/i18n/static-keys.ts @@ -563,4 +563,13 @@ export const STATIC_I18N_KEYS = [ 'Cancelled at', 'Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.', 'Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.', + 'Telegram binding is disabled.', + 'The Telegram authorization request is invalid or expired.', + 'This Telegram binding request has expired or has already been used.', + 'The login session that started this Telegram binding is no longer valid.', + 'This Telegram account is already bound.', + 'This user account no longer exists.', + 'This user account is disabled.', + 'Telegram binding failed. Please try again.', + 'Verification scope is missing', ] as const diff --git a/web/default/src/lib/api.ts b/web/default/src/lib/api.ts index 1647f0faae34..e25f65bbb7b7 100644 --- a/web/default/src/lib/api.ts +++ b/web/default/src/lib/api.ts @@ -22,6 +22,7 @@ export { applyAuthBundle, applyAuthRotation, bootstrapAuthentication, + clearAuthenticatedClientState, clearAuthentication, getCommonHeaders, getFreshAuthHeaders, diff --git a/web/default/src/lib/auth-session.test.ts b/web/default/src/lib/auth-session.test.ts index 7e4f81f1cdb1..7797684b1338 100644 --- a/web/default/src/lib/auth-session.test.ts +++ b/web/default/src/lib/auth-session.test.ts @@ -19,10 +19,13 @@ For commercial licensing, please contact support@quantumnous.com import assert from 'node:assert/strict' import { afterEach, describe, test } from 'node:test' +import { QueryClient } from '@tanstack/react-query' + import { useAuthStore, type AuthBundle } from '../stores/auth-store' import { applyAuthRotation, bootstrapAuthentication, + clearAuthenticatedClientState, createRefreshRunner, isAuthBundle, type AuthRefreshRuntime, @@ -263,4 +266,39 @@ describe('authentication session coordination', () => { ) assert.equal(useAuthStore.getState().auth.accessToken, 'rotated-token') }) + + test('sign-out clears user-scoped query, mutation, and authentication state', () => { + const queryClient = new QueryClient() + queryClient.setQueryData(['account', bundle.user.id], { + username: bundle.user.username, + }) + queryClient.getMutationCache().build(queryClient, { + mutationKey: ['account', bundle.user.id, 'update'], + mutationFn: async () => undefined, + }) + useAuthStore.getState().auth.setBundle(bundle) + useAuthStore.getState().auth.setPending2FAFlowToken('pending-flow') + + clearAuthenticatedClientState(queryClient, false) + + assert.equal(queryClient.getQueryCache().getAll().length, 0) + assert.equal(queryClient.getMutationCache().getAll().length, 0) + assert.equal(useAuthStore.getState().auth.user, null) + assert.equal(useAuthStore.getState().auth.accessToken, null) + assert.equal(useAuthStore.getState().auth.session, null) + assert.equal(useAuthStore.getState().auth.pending2FAFlowToken, null) + assert.equal(useAuthStore.getState().auth.bootstrapState, 'complete') + + const nextBundle: AuthBundle = { + ...bundle, + access_token: 'next-user-token', + user: { id: 84, username: 'next-user', role: 1 }, + session: { ...bundle.session, sid: 'session-b' }, + } + useAuthStore.getState().auth.setBundle(nextBundle) + assert.equal( + queryClient.getQueryData(['account', bundle.user.id]), + undefined + ) + }) }) diff --git a/web/default/src/lib/auth-session.ts b/web/default/src/lib/auth-session.ts index 5fb7a2e9de29..3921af6b4e44 100644 --- a/web/default/src/lib/auth-session.ts +++ b/web/default/src/lib/auth-session.ts @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import type { QueryClient } from '@tanstack/react-query' import axios from 'axios' import { t } from 'i18next' @@ -193,6 +194,14 @@ export function clearAuthentication( } } +export function clearAuthenticatedClientState( + queryClient: QueryClient, + synchronizeTabs = true +): void { + queryClient.clear() + clearAuthentication(synchronizeTabs) +} + function waitForRefreshRace(delay: number): Promise { return new Promise((resolve) => globalThis.setTimeout(resolve, delay)) } diff --git a/web/default/src/lib/server-error-message.test.ts b/web/default/src/lib/server-error-message.test.ts index ef5b2f329748..9e59f4239b07 100644 --- a/web/default/src/lib/server-error-message.test.ts +++ b/web/default/src/lib/server-error-message.test.ts @@ -37,4 +37,34 @@ describe('server error message mapping', () => { assert.match(message ?? '', /rolling window/) assert.equal(getServerErrorMessageKey({ code: 'UNKNOWN_CODE' }), null) }) + + test('maps stable Telegram bind errors without exposing server text', () => { + const expected = { + TELEGRAM_BIND_DISABLED: 'Telegram binding is disabled.', + TELEGRAM_BIND_INVALID_REQUEST: + 'The Telegram authorization request is invalid or expired.', + TELEGRAM_BIND_FLOW_INVALID: + 'This Telegram binding request has expired or has already been used.', + TELEGRAM_BIND_SESSION_INVALID: + 'The login session that started this Telegram binding is no longer valid.', + TELEGRAM_BIND_ALREADY_BOUND: 'This Telegram account is already bound.', + TELEGRAM_BIND_USER_DELETED: 'This user account no longer exists.', + TELEGRAM_BIND_USER_DISABLED: 'This user account is disabled.', + TELEGRAM_BIND_INTERNAL_ERROR: + 'Telegram binding failed. Please try again.', + } + + for (const [code, message] of Object.entries(expected)) { + assert.equal(getServerErrorMessageKey({ code }), message) + } + + assert.equal( + getServerErrorMessageKey({ + response: { + data: { code: 'TELEGRAM_BIND_INTERNAL_ERROR', message: 'raw detail' }, + }, + }), + expected.TELEGRAM_BIND_INTERNAL_ERROR + ) + }) }) diff --git a/web/default/src/lib/server-error-message.ts b/web/default/src/lib/server-error-message.ts index fecbee464b7f..04b9394fa276 100644 --- a/web/default/src/lib/server-error-message.ts +++ b/web/default/src/lib/server-error-message.ts @@ -21,6 +21,17 @@ const serverErrorMessageKeys = { 'Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.', AUTH_SESSION_ISSUANCE_LIMIT: 'Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.', + TELEGRAM_BIND_DISABLED: 'Telegram binding is disabled.', + TELEGRAM_BIND_INVALID_REQUEST: + 'The Telegram authorization request is invalid or expired.', + TELEGRAM_BIND_FLOW_INVALID: + 'This Telegram binding request has expired or has already been used.', + TELEGRAM_BIND_SESSION_INVALID: + 'The login session that started this Telegram binding is no longer valid.', + TELEGRAM_BIND_ALREADY_BOUND: 'This Telegram account is already bound.', + TELEGRAM_BIND_USER_DELETED: 'This user account no longer exists.', + TELEGRAM_BIND_USER_DISABLED: 'This user account is disabled.', + TELEGRAM_BIND_INTERNAL_ERROR: 'Telegram binding failed. Please try again.', } as const function isRecord(value: unknown): value is Record { diff --git a/web/default/src/routes/(auth)/oauth.tsx b/web/default/src/routes/(auth)/oauth.tsx index 5f07360fd620..e917a9b783fd 100644 --- a/web/default/src/routes/(auth)/oauth.tsx +++ b/web/default/src/routes/(auth)/oauth.tsx @@ -22,6 +22,7 @@ import { useEffect } from 'react' import { toast } from 'sonner' import { wechatLoginByCode } from '@/features/auth/api' +import { sanitizeAuthRedirect } from '@/features/auth/lib/auth-redirect' import { applyAuthBundle, isAuthBundle } from '@/lib/api' import { getServerErrorMessageKey } from '@/lib/server-error-message' @@ -41,8 +42,10 @@ function OAuthComponent() { const res = await wechatLoginByCode(search.code) if (res?.success && isAuthBundle(res.data)) { applyAuthBundle(res.data) - const target = search?.redirect || '/dashboard' - navigate({ to: target, replace: true }) + const target = + sanitizeAuthRedirect(search?.redirect, window.location.origin) ?? + '/dashboard' + navigate({ href: target, replace: true }) return } if (getServerErrorMessageKey(res)) { diff --git a/web/default/src/routes/(auth)/sign-in.tsx b/web/default/src/routes/(auth)/sign-in.tsx index ca3b095a49d7..974f026c7c50 100644 --- a/web/default/src/routes/(auth)/sign-in.tsx +++ b/web/default/src/routes/(auth)/sign-in.tsx @@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { createFileRoute, redirect } from '@tanstack/react-router' import { z } from 'zod' +import { sanitizeAuthRedirect } from '@/features/auth/lib/auth-redirect' import { SignIn } from '@/features/auth/sign-in' import { useAuthStore } from '@/stores/auth-store' @@ -34,9 +35,10 @@ export const Route = createFileRoute('/(auth)/sign-in')({ // 如果已经有用户信息,说明已登录 if (auth.user) { - // 优先使用 redirect 参数(用户之前想去的地方) - // 否则跳转到 dashboard - throw redirect({ to: search?.redirect || '/dashboard' }) + const target = + sanitizeAuthRedirect(search?.redirect, window.location.origin) ?? + '/dashboard' + throw redirect({ href: target, replace: true }) } }, }) diff --git a/web/default/src/routes/__root.tsx b/web/default/src/routes/__root.tsx index 8b89bb0e79b4..89d8cb5c102f 100644 --- a/web/default/src/routes/__root.tsx +++ b/web/default/src/routes/__root.tsx @@ -22,6 +22,7 @@ import { createRootRouteWithContext, Outlet, redirect, + useNavigate, } from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' import { useEffect } from 'react' @@ -36,12 +37,14 @@ import { getSetupStatus } from '@/features/setup/api' import { useSystemConfig } from '@/hooks/use-system-config' import { bootstrapAuthentication, + clearAuthenticatedClientState, clearAuthentication, } from '@/lib/auth-session' import { subscribeAuthSessionEvents } from '@/lib/auth-session-sync' import { useAuthStore } from '@/stores/auth-store' function RootComponent() { + const navigate = useNavigate() const queryClient = useQueryClient() // Load system configuration (logo, system name, etc.) from backend @@ -81,11 +84,11 @@ function RootComponent() { } if (currentSID && event.sid === currentSID) { - clearAuthentication(false) - window.location.replace('/sign-in') + clearAuthenticatedClientState(queryClient, false) + void navigate({ to: '/sign-in', replace: true }) } }), - [] + [navigate, queryClient] ) return ( diff --git a/web/default/src/routes/oauth/$provider.tsx b/web/default/src/routes/oauth/$provider.tsx index e08363732f41..bb7ca44a57a2 100644 --- a/web/default/src/routes/oauth/$provider.tsx +++ b/web/default/src/routes/oauth/$provider.tsx @@ -31,9 +31,13 @@ import { OAuthCallbackScreen } from '@/features/auth/components/oauth-callback-s import { OAUTH_BIND_CALLBACK_MESSAGE, OAUTH_BIND_RESULT_MESSAGE, - TELEGRAM_BIND_RESULT_MESSAGE, } from '@/features/auth/constants' -import { startOAuthBindResponseDeadline } from '@/features/auth/lib/oauth-bind-window' +import { sanitizeAuthRedirect } from '@/features/auth/lib/auth-redirect' +import { + parseTelegramBindCallback, + postTelegramBindResult, + startOAuthBindResponseDeadline, +} from '@/features/auth/lib/oauth-bind-window' import { api, applyAuthBundle, isAuthBundle } from '@/lib/api' import { getServerErrorMessageKey } from '@/lib/server-error-message' @@ -62,6 +66,7 @@ function OAuthCallback() { redirect?: string telegram_bind?: string flow_token?: string + error_code?: string } const mode: 'login' | 'bind' = typeof window !== 'undefined' && window.opener ? 'bind' : 'login' @@ -71,27 +76,35 @@ function OAuthCallback() { const code = search.code ?? '' const state = search.state ?? '' - if (mode === 'bind') { + const telegramCallback = + provider === 'telegram' + ? parseTelegramBindCallback({ + telegram_bind: search.telegram_bind, + flow_token: search.flow_token, + error_code: search.error_code, + }) + : null + if (telegramCallback) { const opener = window.opener - if (!opener || opener.closed) { - toast.error(i18next.t('OAuth binding window is no longer available')) - return - } - if ( - provider === 'telegram' && - search.telegram_bind === 'success' && - search.flow_token - ) { - opener.postMessage( - { - type: TELEGRAM_BIND_RESULT_MESSAGE, - flow_token: search.flow_token, - success: true, - }, + !postTelegramBindResult( + telegramCallback, + opener, window.location.origin ) - window.close() + ) { + toast.error(i18next.t('Telegram binding failed. Please try again.')) + const closeTimeout = window.setTimeout(() => window.close(), 1500) + return () => window.clearTimeout(closeTimeout) + } + window.close() + return + } + + if (mode === 'bind') { + const opener = window.opener + if (!opener || opener.closed) { + toast.error(i18next.t('OAuth binding window is no longer available')) return } @@ -146,23 +159,15 @@ function OAuthCallback() { } } - const safeNavigate = (target: string) => { - navigate({ to: target as never, replace: true }) - setTimeout(() => { - const normalizedTarget = target.startsWith('/') ? target : `/${target}` - const currentPath = window.location.pathname + window.location.search - if ( - currentPath !== normalizedTarget && - currentPath !== `${normalizedTarget}/` - ) { - window.location.replace(target) - } - }, 100) + const safeNavigate = (target: unknown, fallback = '/dashboard') => { + const href = + sanitizeAuthRedirect(target, window.location.origin) ?? fallback + void navigate({ href, replace: true }) } if (!code && !search.error) { toast.error(i18next.t('Missing code')) - safeNavigate('/sign-in') + safeNavigate('/sign-in', '/sign-in') return } @@ -180,7 +185,7 @@ function OAuthCallback() { const response = await api.get(`/api/oauth/${provider}`, config) if (response.data?.success && isAuthBundle(response.data?.data)) { applyAuthBundle(response.data.data) - safeNavigate(search.redirect || '/dashboard') + safeNavigate(search.redirect) toast.success(i18next.t('Signed in successfully!')) return } @@ -204,7 +209,7 @@ function OAuthCallback() { ) } } - safeNavigate('/sign-in') + safeNavigate('/sign-in', '/sign-in') })() }, [ mode, @@ -212,6 +217,7 @@ function OAuthCallback() { provider, search.code, search.error, + search.error_code, search.error_description, search.flow_token, search.redirect, From 9b60513ea7f3481154a4a7d3e591f7a8b1364275 Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 20 Jul 2026 16:34:55 +0800 Subject: [PATCH 5/5] refactor: remove classic frontend and flatten web app --- .../skills/classic-to-default-sync/SKILL.md | 83 - .agents/skills/i18n-translate/SKILL.md | 32 +- .agents/skills/shadcn-ui/SKILL.md | 10 +- .dockerignore | 5 +- .github/workflows/electron-build.yml | 4 +- .github/workflows/release.yml | 42 +- .gitignore | 6 +- AGENTS.md | 20 +- Dockerfile | 20 +- Dockerfile.dev | 7 +- README.en.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.md | 2 +- README.zh_CN.md | 2 +- README.zh_TW.md | 2 +- THIRD-PARTY-LICENSES.md | 201 +- common/constants.go | 40 - common/embed-file-system.go | 26 - controller/audit.go | 2 +- controller/console_migrate.go | 106 - controller/log.go | 26 - controller/misc.go | 2 +- controller/option.go | 7 +- controller/return_path.go | 3 +- controller/return_path_test.go | 25 + controller/subscription_payment_epay.go | 14 +- controller/subscription_payment_stripe.go | 4 +- controller/telegram.go | 67 +- controller/telegram_test.go | 96 +- controller/theme_compat_test.go | 47 + controller/topup.go | 10 +- controller/topup_stripe.go | 4 +- controller/topup_waffo.go | 2 +- docker-compose.dev.yml | 6 +- docs/openapi/api.json | 69 +- electron/README.md | 22 +- electron/build.sh | 5 +- electron/main.js | 8 +- electron/package.json | 4 - main.go | 25 +- makefile | 34 +- middleware/audit.go | 4 +- model/frontend_option_migration.go | 239 + model/frontend_option_migration_test.go | 180 + model/log.go | 31 +- model/option.go | 8 +- router/api-router.go | 4 - router/main.go | 2 +- router/retired_frontend_routes_test.go | 26 + router/web-router.go | 24 +- service/quota.go | 4 +- service/return_path.go | 3 +- service/return_path_test.go | 16 + setting/system_setting/theme.go | 32 - web/{default => }/.gitignore | 0 web/{default => }/.node-version | 0 web/{default => }/.npmrc | 0 web/{default => }/.oxfmtrc.json | 0 web/{default => }/.oxlintrc.json | 0 web/{default => }/AGENTS.md | 2 +- web/bun.lock | 1516 ++---- web/classic/.eslintrc.cjs | 42 - web/classic/.gitignore | 26 - web/classic/.prettierrc.mjs | 1 - web/classic/i18next.config.js | 84 - web/classic/index.html | 29 - web/classic/jsconfig.json | 9 - web/classic/package.json | 97 - web/classic/postcss.config.js | 25 - web/classic/public/azure_model_name.png | Bin 256912 -> 0 bytes web/classic/public/cover-4.webp | Bin 54144 -> 0 bytes web/classic/public/ratio.png | Bin 143438 -> 0 bytes web/classic/public/robots.txt | 3 - web/classic/rsbuild.config.ts | 106 - web/classic/src/App.jsx | 386 -- web/classic/src/components/auth/LoginForm.jsx | 983 ---- .../src/components/auth/OAuth2Callback.jsx | 107 - .../components/auth/PasswordResetConfirm.jsx | 220 - .../src/components/auth/PasswordResetForm.jsx | 193 - .../src/components/auth/RegisterForm.jsx | 805 ---- .../src/components/auth/TwoFAVerification.jsx | 244 - .../common/DocumentRenderer/index.jsx | 232 - .../src/components/common/ErrorBoundary.jsx | 52 - .../common/examples/ChannelKeyViewExample.jsx | 113 - .../components/common/logo/LinuxDoIcon.jsx | 56 - .../src/components/common/logo/OIDCIcon.jsx | 57 - .../src/components/common/logo/WeChatIcon.jsx | 55 - .../common/markdown/MarkdownRenderer.jsx | 697 --- .../components/common/markdown/markdown.css | 449 -- .../modals/RiskAcknowledgementModal.jsx | 298 -- .../common/modals/SecureVerificationModal.jsx | 322 -- .../common/modals/TwoFactorAuthModal.jsx | 148 - .../src/components/common/ui/CardPro.jsx | 200 - .../src/components/common/ui/CardTable.jsx | 242 - .../common/ui/ChannelKeyDisplay.jsx | 280 -- .../common/ui/CompactModeToggle.jsx | 68 - .../src/components/common/ui/JSONEditor.jsx | 718 --- .../src/components/common/ui/Loading.jsx | 31 - .../src/components/common/ui/RenderUtils.jsx | 60 - .../common/ui/ScrollableContainer.jsx | 242 - .../common/ui/SelectableButtonGroup.jsx | 295 -- .../dashboard/AnnouncementsPanel.jsx | 126 - .../src/components/dashboard/ApiInfoPanel.jsx | 126 - .../src/components/dashboard/ChartsPanel.jsx | 95 - .../components/dashboard/DashboardHeader.jsx | 61 - .../src/components/dashboard/FaqPanel.jsx | 88 - .../src/components/dashboard/StatsCards.jsx | 116 - .../src/components/dashboard/UptimePanel.jsx | 152 - .../src/components/dashboard/index.jsx | 286 -- .../dashboard/modals/SearchModal.jsx | 103 - .../ClassicFrontendDeprecationBanner.jsx | 125 - web/classic/src/components/layout/Footer.jsx | 252 - .../src/components/layout/NoticeModal.jsx | 255 - .../src/components/layout/PageLayout.jsx | 248 - .../src/components/layout/SetupCheck.js | 40 - .../src/components/layout/SiderBar.jsx | 536 --- .../layout/components/SkeletonWrapper.jsx | 379 -- .../layout/headerbar/ActionButtons.jsx | 74 - .../layout/headerbar/HeaderLogo.jsx | 81 - .../layout/headerbar/LanguageSelector.jsx | 86 - .../layout/headerbar/MobileMenuButton.jsx | 56 - .../layout/headerbar/Navigation.jsx | 88 - .../layout/headerbar/NewYearButton.jsx | 62 - .../layout/headerbar/NotificationButton.jsx | 46 - .../layout/headerbar/ThemeToggle.jsx | 111 - .../components/layout/headerbar/UserArea.jsx | 200 - .../src/components/layout/headerbar/index.jsx | 132 - .../DeploymentAccessGuard.jsx | 412 -- .../src/components/playground/ChatArea.jsx | 129 - .../src/components/playground/CodeViewer.jsx | 401 -- .../components/playground/ConfigManager.jsx | 281 -- .../playground/CustomInputRender.jsx | 155 - .../playground/CustomRequestEditor.jsx | 217 - .../src/components/playground/DebugPanel.jsx | 224 - .../components/playground/FloatingButtons.jsx | 86 - .../components/playground/ImageUrlInput.jsx | 142 - .../components/playground/MessageActions.jsx | 152 - .../components/playground/MessageContent.jsx | 412 -- .../playground/OptimizedComponents.js | 97 - .../playground/ParameterControl.jsx | 303 -- .../src/components/playground/SSEViewer.jsx | 314 -- .../components/playground/SettingsPanel.jsx | 245 - .../components/playground/ThinkingContent.jsx | 180 - .../components/playground/configStorage.js | 234 - .../src/components/playground/index.js | 40 - .../settings/ChannelSelectorModal.jsx | 310 -- .../src/components/settings/ChatsSetting.jsx | 82 - .../settings/CustomOAuthSetting.jsx | 1053 ---- .../components/settings/DashboardSetting.jsx | 173 - .../components/settings/DrawingSetting.jsx | 84 - .../settings/HttpStatusCodeRulesInput.jsx | 70 - .../settings/ModelDeploymentSetting.jsx | 85 - .../src/components/settings/ModelSetting.jsx | 137 - .../components/settings/OperationSetting.jsx | 162 - .../src/components/settings/OtherSetting.jsx | 549 --- .../components/settings/PaymentSetting.jsx | 331 -- .../settings/PerformanceSetting.jsx | 80 - .../components/settings/PersonalSetting.jsx | 668 --- .../components/settings/RateLimitSetting.jsx | 89 - .../src/components/settings/RatioSetting.jsx | 121 - .../src/components/settings/SystemSetting.jsx | 1724 ------- .../personal/cards/AccountManagement.jsx | 774 --- .../personal/cards/CheckinCalendar.jsx | 384 -- .../settings/personal/cards/ModelsList.jsx | 280 -- .../personal/cards/NotificationSettings.jsx | 950 ---- .../personal/cards/PreferencesSettings.jsx | 182 - .../personal/components/TwoFASetting.jsx | 723 --- .../personal/components/UserInfoHeader.jsx | 220 - .../personal/modals/AccountDeleteModal.jsx | 94 - .../personal/modals/ChangePasswordModal.jsx | 117 - .../personal/modals/EmailBindModal.jsx | 108 - .../personal/modals/WeChatBindModal.jsx | 80 - .../src/components/setup/SetupWizard.jsx | 330 -- .../setup/components/StepNavigation.jsx | 71 - .../setup/components/steps/AdminStep.jsx | 120 - .../setup/components/steps/CompleteStep.jsx | 75 - .../setup/components/steps/DatabaseStep.jsx | 130 - .../setup/components/steps/UsageModeStep.jsx | 71 - web/classic/src/components/setup/index.jsx | 29 - .../table/channels/ChannelsActions.jsx | 329 -- .../table/channels/ChannelsColumnDefs.jsx | 906 ---- .../table/channels/ChannelsFilters.jsx | 159 - .../table/channels/ChannelsTable.jsx | 177 - .../table/channels/ChannelsTabs.jsx | 97 - .../src/components/table/channels/index.jsx | 116 - .../table/channels/modals/BatchTagModal.jsx | 63 - .../modals/ChannelUpstreamUpdateModal.jsx | 313 -- .../table/channels/modals/CodexUsageModal.jsx | 718 --- .../channels/modals/ColumnSelectorModal.jsx | 128 - .../channels/modals/EditChannelModal.jsx | 3931 --------------- .../table/channels/modals/EditTagModal.jsx | 754 --- .../channels/modals/ModelSelectModal.jsx | 468 -- .../table/channels/modals/ModelTestModal.jsx | 396 -- .../channels/modals/MultiKeyManageModal.jsx | 742 --- .../channels/modals/OllamaModelModal.jsx | 778 --- .../modals/ParamOverrideEditorModal.jsx | 3511 -------------- .../modals/SingleModelSelectModal.jsx | 195 - .../modals/StatusCodeRiskGuardModal.jsx | 41 - .../channels/modals/statusCodeRiskGuard.js | 132 - .../table/mj-logs/MjLogsActions.jsx | 69 - .../table/mj-logs/MjLogsColumnDefs.jsx | 511 -- .../table/mj-logs/MjLogsFilters.jsx | 130 - .../components/table/mj-logs/MjLogsTable.jsx | 108 - .../src/components/table/mj-logs/index.jsx | 65 - .../mj-logs/modals/ColumnSelectorModal.jsx | 109 - .../table/mj-logs/modals/ContentModal.jsx | 55 - .../model-deployments/DeploymentsActions.jsx | 109 - .../DeploymentsColumnDefs.jsx | 702 --- .../model-deployments/DeploymentsFilters.jsx | 130 - .../model-deployments/DeploymentsTable.jsx | 247 - .../table/model-deployments/index.jsx | 152 - .../modals/ColumnSelectorModal.jsx | 127 - .../modals/ConfirmationDialog.jsx | 99 - .../modals/CreateDeploymentModal.jsx | 1511 ------ .../modals/EditDeploymentModal.jsx | 241 - .../modals/ExtendDurationModal.jsx | 542 --- .../modals/UpdateConfigModal.jsx | 497 -- .../modals/ViewDetailsModal.jsx | 601 --- .../modals/ViewLogsModal.jsx | 723 --- .../filter/PricingDisplaySettings.jsx | 123 - .../filter/PricingEndpointTypes.jsx | 103 - .../model-pricing/filter/PricingGroups.jsx | 84 - .../filter/PricingQuotaTypes.jsx | 61 - .../model-pricing/filter/PricingTags.jsx | 109 - .../model-pricing/filter/PricingVendors.jsx | 127 - .../model-pricing/layout/PricingPage.jsx | 86 - .../model-pricing/layout/PricingSidebar.jsx | 155 - .../layout/content/PricingContent.jsx | 60 - .../layout/content/PricingView.jsx | 32 - .../layout/header/PricingTopSection.jsx | 124 - .../layout/header/PricingVendorIntro.jsx | 419 -- .../header/PricingVendorIntroSkeleton.jsx | 212 - .../header/PricingVendorIntroWithSkeleton.jsx | 44 - .../layout/header/SearchActions.jsx | 163 - .../modal/ModelDetailSideSheet.jsx | 132 - .../modal/PricingFilterModal.jsx | 67 - .../components/DynamicPricingBreakdown.jsx | 206 - .../modal/components/FilterModalContent.jsx | 140 - .../modal/components/FilterModalFooter.jsx | 36 - .../modal/components/ModelBasicInfo.jsx | 89 - .../modal/components/ModelEndpoints.jsx | 82 - .../modal/components/ModelHeader.jsx | 96 - .../modal/components/ModelPricingTable.jsx | 201 - .../view/card/PricingCardSkeleton.jsx | 144 - .../view/card/PricingCardView.jsx | 389 -- .../model-pricing/view/table/PricingTable.jsx | 147 - .../view/table/PricingTableColumns.jsx | 259 - .../components/table/models/ModelsActions.jsx | 259 - .../table/models/ModelsColumnDefs.jsx | 380 -- .../table/models/ModelsDescription.jsx | 44 - .../components/table/models/ModelsFilters.jsx | 106 - .../components/table/models/ModelsTable.jsx | 108 - .../components/table/models/ModelsTabs.jsx | 178 - .../components/SelectionNotification.jsx | 100 - .../src/components/table/models/index.jsx | 210 - .../table/models/modals/EditModelModal.jsx | 554 --- .../models/modals/EditPrefillGroupModal.jsx | 275 -- .../table/models/modals/EditVendorModal.jsx | 186 - .../models/modals/MissingModelsModal.jsx | 198 - .../models/modals/PrefillGroupManagement.jsx | 308 -- .../table/models/modals/SyncWizardModal.jsx | 135 - .../models/modals/UpstreamConflictModal.jsx | 324 -- .../table/redemptions/RedemptionsActions.jsx | 71 - .../redemptions/RedemptionsColumnDefs.jsx | 222 - .../redemptions/RedemptionsDescription.jsx | 44 - .../table/redemptions/RedemptionsFilters.jsx | 93 - .../table/redemptions/RedemptionsTable.jsx | 144 - .../components/table/redemptions/index.jsx | 122 - .../modals/DeleteRedemptionModal.jsx | 58 - .../modals/EditRedemptionModal.jsx | 392 -- .../subscriptions/SubscriptionsActions.jsx | 39 - .../subscriptions/SubscriptionsColumnDefs.jsx | 375 -- .../SubscriptionsDescription.jsx | 44 - .../subscriptions/SubscriptionsTable.jsx | 88 - .../components/table/subscriptions/index.jsx | 139 - .../modals/AddEditSubscriptionModal.jsx | 553 --- .../table/task-logs/TaskLogsActions.jsx | 43 - .../table/task-logs/TaskLogsColumnDefs.jsx | 450 -- .../table/task-logs/TaskLogsFilters.jsx | 131 - .../table/task-logs/TaskLogsTable.jsx | 112 - .../src/components/table/task-logs/index.jsx | 78 - .../task-logs/modals/AudioPreviewModal.jsx | 181 - .../task-logs/modals/ColumnSelectorModal.jsx | 103 - .../table/task-logs/modals/ContentModal.jsx | 179 - .../components/table/tokens/TokensActions.jsx | 116 - .../table/tokens/TokensColumnDefs.jsx | 574 --- .../table/tokens/TokensDescription.jsx | 44 - .../components/table/tokens/TokensFilters.jsx | 106 - .../components/table/tokens/TokensTable.jsx | 136 - .../src/components/table/tokens/index.jsx | 443 -- .../table/tokens/modals/CCSwitchModal.jsx | 194 - .../table/tokens/modals/CopyTokensModal.jsx | 61 - .../table/tokens/modals/DeleteTokensModal.jsx | 47 - .../table/tokens/modals/EditTokenModal.jsx | 653 --- .../table/usage-logs/UsageLogsActions.jsx | 95 - .../table/usage-logs/UsageLogsColumnDefs.jsx | 936 ---- .../table/usage-logs/UsageLogsFilters.jsx | 193 - .../table/usage-logs/UsageLogsTable.jsx | 132 - .../components/ParamOverrideEntry.jsx | 54 - .../src/components/table/usage-logs/index.jsx | 67 - .../modals/ChannelAffinityUsageCacheModal.jsx | 241 - .../usage-logs/modals/ColumnSelectorModal.jsx | 134 - .../usage-logs/modals/ParamOverrideModal.jsx | 272 -- .../table/usage-logs/modals/UserInfoModal.jsx | 177 - .../components/table/users/UsersActions.jsx | 38 - .../table/users/UsersColumnDefs.jsx | 390 -- .../table/users/UsersDescription.jsx | 43 - .../components/table/users/UsersFilters.jsx | 115 - .../src/components/table/users/UsersTable.jsx | 267 -- .../src/components/table/users/index.jsx | 124 - .../table/users/modals/AddUserModal.jsx | 186 - .../table/users/modals/DeleteUserModal.jsx | 58 - .../table/users/modals/DemoteUserModal.jsx | 37 - .../table/users/modals/EditUserModal.jsx | 571 --- .../users/modals/EnableDisableUserModal.jsx | 46 - .../table/users/modals/PromoteUserModal.jsx | 37 - .../table/users/modals/ResetPasskeyModal.jsx | 40 - .../table/users/modals/ResetTwoFAModal.jsx | 42 - .../modals/UserBindingManagementModal.jsx | 433 -- .../users/modals/UserSubscriptionsModal.jsx | 433 -- .../src/components/topup/InvitationCard.jsx | 241 - .../src/components/topup/RechargeCard.jsx | 716 --- .../topup/SubscriptionPlansCard.jsx | 692 --- web/classic/src/components/topup/index.jsx | 1034 ---- .../topup/modals/PaymentConfirmModal.jsx | 221 - .../modals/SubscriptionPurchaseModal.jsx | 259 - .../topup/modals/TopupHistoryModal.jsx | 302 -- .../components/topup/modals/TransferModal.jsx | 77 - .../src/constants/billing.constants.js | 56 - .../channel-affinity-template.constants.js | 116 - .../src/constants/channel.constants.js | 199 - web/classic/src/constants/common.constant.js | 46 - .../src/constants/console.constants.js | 49 - .../src/constants/dashboard.constants.js | 149 - web/classic/src/constants/index.js | 62 - .../src/constants/playground.constants.js | 131 - .../src/constants/redemption.constants.js | 47 - web/classic/src/constants/toast.constants.js | 26 - web/classic/src/constants/user.constants.js | 38 - web/classic/src/context/Status/index.jsx | 36 - web/classic/src/context/Status/reducer.js | 39 - web/classic/src/context/Theme/index.jsx | 114 - web/classic/src/context/User/index.jsx | 57 - web/classic/src/context/User/reducer.js | 40 - .../src/contexts/PlaygroundContext.jsx | 60 - web/classic/src/helpers/api.js | 397 -- web/classic/src/helpers/auth.jsx | 68 - web/classic/src/helpers/base64.js | 56 - web/classic/src/helpers/boolean.js | 29 - web/classic/src/helpers/dashboard.jsx | 444 -- web/classic/src/helpers/data.js | 61 - web/classic/src/helpers/frontendTheme.js | 70 - web/classic/src/helpers/history.js | 22 - web/classic/src/helpers/index.js | 33 - web/classic/src/helpers/log.js | 33 - web/classic/src/helpers/passkey.js | 177 - web/classic/src/helpers/quota.js | 47 - web/classic/src/helpers/render.jsx | 3355 ------------- web/classic/src/helpers/secureApiCall.js | 62 - web/classic/src/helpers/statusCodeRules.js | 118 - web/classic/src/helpers/subscriptionFormat.js | 34 - web/classic/src/helpers/token.js | 134 - web/classic/src/helpers/utils.jsx | 1098 ----- .../src/hooks/channels/upstreamUpdateUtils.js | 56 - .../channels/useChannelUpstreamUpdates.jsx | 309 -- .../src/hooks/channels/useChannelsData.jsx | 1257 ----- web/classic/src/hooks/chat/useTokenKeys.js | 49 - .../src/hooks/common/useContainerWidth.js | 52 - web/classic/src/hooks/common/useHeaderBar.js | 250 - web/classic/src/hooks/common/useIsMobile.js | 35 - .../src/hooks/common/useMinimumLoadingTime.js | 50 - web/classic/src/hooks/common/useNavigation.js | 87 - .../src/hooks/common/useNotifications.js | 94 - .../hooks/common/useSecureVerification.jsx | 274 -- web/classic/src/hooks/common/useSidebar.js | 301 -- .../src/hooks/common/useSidebarCollapsed.js | 43 - .../src/hooks/common/useTableCompactMode.js | 58 - .../src/hooks/common/useUserPermissions.js | 119 - .../hooks/dashboard/useDashboardCharts.jsx | 628 --- .../src/hooks/dashboard/useDashboardData.js | 346 -- .../src/hooks/dashboard/useDashboardStats.jsx | 153 - .../src/hooks/mj-logs/useMjLogsData.js | 338 -- .../useDeploymentResources.js | 312 -- .../model-deployments/useDeploymentsData.jsx | 522 -- .../useEnhancedDeploymentActions.jsx | 286 -- .../useModelDeploymentSettings.js | 137 - .../model-pricing/useModelPricingData.jsx | 408 -- .../model-pricing/usePricingFilterCounts.js | 174 - .../src/hooks/models/useModelsData.jsx | 497 -- .../src/hooks/playground/useApiRequest.jsx | 555 --- .../src/hooks/playground/useDataLoader.js | 95 - .../hooks/playground/useMessageActions.jsx | 291 -- .../src/hooks/playground/useMessageEdit.jsx | 157 - .../hooks/playground/usePlaygroundState.js | 313 -- .../playground/useSyncMessageAndCustomBody.js | 149 - .../hooks/redemptions/useRedemptionsData.jsx | 360 -- .../subscriptions/useSubscriptionsData.jsx | 166 - .../src/hooks/task-logs/useTaskLogsData.js | 376 -- .../src/hooks/tokens/useTokensData.jsx | 522 -- .../src/hooks/usage-logs/useUsageLogsData.jsx | 900 ---- web/classic/src/hooks/users/useUsersData.jsx | 321 -- web/classic/src/i18n/i18n.js | 57 - web/classic/src/i18n/language.js | 61 - web/classic/src/i18n/locales/en.json | 3835 --------------- web/classic/src/i18n/locales/fr.json | 3689 -------------- web/classic/src/i18n/locales/ja.json | 3658 -------------- web/classic/src/i18n/locales/ru.json | 3709 --------------- web/classic/src/i18n/locales/vi.json | 4223 ----------------- web/classic/src/i18n/locales/zh-CN.json | 3818 --------------- web/classic/src/i18n/locales/zh-TW.json | 3682 -------------- web/classic/src/i18n/locales/zh.json | 2635 ---------- web/classic/src/index.css | 1094 ----- web/classic/src/index.jsx | 79 - web/classic/src/pages/About/index.jsx | 178 - web/classic/src/pages/Channel/index.jsx | 31 - web/classic/src/pages/Chat/index.jsx | 83 - web/classic/src/pages/Chat2Link/index.jsx | 45 - web/classic/src/pages/Dashboard/index.jsx | 29 - web/classic/src/pages/Forbidden/index.jsx | 43 - web/classic/src/pages/Home/index.jsx | 356 -- web/classic/src/pages/Log/index.jsx | 29 - web/classic/src/pages/Midjourney/index.jsx | 29 - web/classic/src/pages/Model/index.jsx | 30 - .../src/pages/ModelDeployment/index.jsx | 51 - web/classic/src/pages/NotFound/index.jsx | 43 - web/classic/src/pages/Playground/index.jsx | 565 --- web/classic/src/pages/Pricing/index.jsx | 29 - web/classic/src/pages/PrivacyPolicy/index.jsx | 37 - web/classic/src/pages/Redemption/index.jsx | 31 - .../src/pages/Setting/Chat/SettingsChats.jsx | 563 --- .../Setting/Dashboard/SettingsAPIInfo.jsx | 514 -- .../Dashboard/SettingsAnnouncements.jsx | 634 --- .../Dashboard/SettingsDataDashboard.jsx | 170 - .../pages/Setting/Dashboard/SettingsFAQ.jsx | 490 -- .../Setting/Dashboard/SettingsUptimeKuma.jsx | 522 -- .../pages/Setting/Drawing/SettingsDrawing.jsx | 210 - .../Setting/Model/SettingClaudeModel.jsx | 250 - .../Setting/Model/SettingGeminiModel.jsx | 306 -- .../Setting/Model/SettingGlobalModel.jsx | 416 -- .../pages/Setting/Model/SettingGrokModel.jsx | 174 - .../Setting/Model/SettingModelDeployment.jsx | 333 -- .../Operation/SettingsChannelAffinity.jsx | 1442 ------ .../Setting/Operation/SettingsCheckin.jsx | 152 - .../Setting/Operation/SettingsCreditLimit.jsx | 215 - .../Setting/Operation/SettingsGeneral.jsx | 436 -- .../Operation/SettingsHeaderNavModules.jsx | 356 -- .../pages/Setting/Operation/SettingsLog.jsx | 261 - .../Setting/Operation/SettingsMonitoring.jsx | 290 -- .../Operation/SettingsSensitiveWords.jsx | 157 - .../Operation/SettingsSidebarModulesAdmin.jsx | 438 -- .../Payment/SettingsGeneralPayment.jsx | 248 - .../Payment/SettingsPaymentGateway.jsx | 185 - .../Payment/SettingsPaymentGatewayCreem.jsx | 430 -- .../Payment/SettingsPaymentGatewayStripe.jsx | 266 -- .../Payment/SettingsPaymentGatewayWaffo.jsx | 701 --- .../SettingsPaymentGatewayWaffoPancake.jsx | 202 - .../Performance/SettingsPerformance.jsx | 736 --- .../Personal/SettingsSidebarModulesUser.jsx | 482 -- .../RateLimit/SettingsRequestRateLimit.jsx | 242 - .../Setting/Ratio/GroupRatioSettings.jsx | 758 --- .../Setting/Ratio/ModelPricingCombined.jsx | 50 - .../Setting/Ratio/ModelRatioSettings.jsx | 348 -- .../Setting/Ratio/ModelRationNotSetEditor.jsx | 64 - .../Ratio/ModelSettingsVisualEditor.jsx | 25 - .../pages/Setting/Ratio/ToolPriceSettings.jsx | 283 -- .../pages/Setting/Ratio/UpstreamRatioSync.jsx | 1118 ----- .../Ratio/components/AutoGroupList.jsx | 169 - .../Ratio/components/GroupGroupRatioRules.jsx | 287 -- .../components/GroupSpecialUsableRules.jsx | 351 -- .../Setting/Ratio/components/GroupTable.jsx | 251 - .../Ratio/components/ModelPricingEditor.jsx | 781 --- .../Ratio/components/TieredPricingEditor.jsx | 1697 ------- .../Ratio/components/requestRuleExpr.js | 443 -- .../Ratio/hooks/useModelPricingEditorState.js | 1133 ----- web/classic/src/pages/Setting/index.jsx | 217 - web/classic/src/pages/Setup/index.jsx | 31 - web/classic/src/pages/Subscription/index.jsx | 31 - web/classic/src/pages/Task/index.jsx | 29 - web/classic/src/pages/Token/index.jsx | 31 - web/classic/src/pages/TopUp/index.js | 22 - web/classic/src/pages/User/index.jsx | 31 - web/classic/src/pages/UserAgreement/index.jsx | 37 - .../src/services/secureVerification.js | 232 - web/classic/tailwind.config.js | 149 - web/classic/vercel.json | 5 - web/{default => }/components.json | 0 web/{default => }/cz.yaml | 0 web/default/package.json | 111 - web/default/public/favicon.ico | Bin 15406 -> 0 bytes web/default/public/logo.png | Bin 9597 -> 0 bytes web/default/public/pay-apple.png | Bin 1597 -> 0 bytes web/default/public/pay-card.png | Bin 3685 -> 0 bytes web/default/public/pay-google.png | Bin 4644 -> 0 bytes web/default/public/waffo-logo-dark.svg | 5 - web/default/public/waffo-logo-light.svg | 5 - web/default/src/components/truncated-text.tsx | 22 - .../src/components/ui/dropdown-menu-events.ts | 25 - .../components/dialogs/wechat-bind-dialog.tsx | 76 - web/{default => }/index.html | 0 web/{default => }/knip.config.ts | 0 web/{default => }/netlify.toml | 0 web/package.json | 116 +- web/{classic => }/public/favicon.ico | Bin web/{classic => }/public/logo.png | Bin web/{classic => }/public/pay-apple.png | Bin web/{classic => }/public/pay-card.png | Bin web/{classic => }/public/pay-google.png | Bin web/{classic => }/public/waffo-logo-dark.svg | 0 web/{classic => }/public/waffo-logo-light.svg | 0 web/{default => }/rsbuild.config.ts | 0 web/{default => }/scripts/add-copyright.mjs | 9 +- .../scripts/format-with-protected-headers.mjs | 0 web/{default => }/scripts/sync-i18n.mjs | 0 .../src/assets/brand-icons/icon-discord.tsx | 0 .../src/assets/brand-icons/icon-docker.tsx | 0 .../src/assets/brand-icons/icon-facebook.tsx | 0 .../src/assets/brand-icons/icon-figma.tsx | 0 .../src/assets/brand-icons/icon-github.tsx | 0 .../src/assets/brand-icons/icon-gitlab.tsx | 0 .../src/assets/brand-icons/icon-gmail.tsx | 0 .../src/assets/brand-icons/icon-linuxdo.tsx | 0 .../src/assets/brand-icons/icon-medium.tsx | 0 .../src/assets/brand-icons/icon-notion.tsx | 0 .../src/assets/brand-icons/icon-skype.tsx | 0 .../src/assets/brand-icons/icon-slack.tsx | 0 .../src/assets/brand-icons/icon-stripe.tsx | 0 .../src/assets/brand-icons/icon-telegram.tsx | 0 .../src/assets/brand-icons/icon-trello.tsx | 0 .../src/assets/brand-icons/icon-wechat.tsx | 0 .../src/assets/brand-icons/icon-whatsapp.tsx | 0 .../src/assets/brand-icons/icon-zoom.tsx | 0 .../src/assets/brand-icons/index.ts | 0 .../src/assets/clerk-full-logo.tsx | 0 web/{default => }/src/assets/clerk-logo.tsx | 0 .../src/assets/custom/icon-dir.tsx | 0 .../src/assets/custom/icon-layout-compact.tsx | 0 .../src/assets/custom/icon-layout-default.tsx | 0 .../src/assets/custom/icon-layout-full.tsx | 0 .../assets/custom/icon-sidebar-floating.tsx | 0 .../src/assets/custom/icon-sidebar-inset.tsx | 0 .../assets/custom/icon-sidebar-sidebar.tsx | 0 .../src/assets/custom/icon-theme-dark.tsx | 0 .../src/assets/custom/icon-theme-light.tsx | 0 .../src/assets/custom/icon-theme-system.tsx | 0 web/{default => }/src/assets/logo.tsx | 0 .../src/components/ai-elements/actions.tsx | 0 .../src/components/ai-elements/artifact.tsx | 0 .../src/components/ai-elements/branch.tsx | 0 .../src/components/ai-elements/canvas.tsx | 0 .../ai-elements/chain-of-thought.tsx | 0 .../src/components/ai-elements/code-block.tsx | 0 .../components/ai-elements/confirmation.tsx | 0 .../src/components/ai-elements/connection.tsx | 0 .../src/components/ai-elements/context.tsx | 0 .../src/components/ai-elements/controls.tsx | 0 .../components/ai-elements/conversation.tsx | 0 .../src/components/ai-elements/edge.tsx | 0 .../src/components/ai-elements/image.tsx | 0 .../ai-elements/inline-citation.tsx | 0 .../src/components/ai-elements/loader.tsx | 0 .../src/components/ai-elements/message.tsx | 0 .../src/components/ai-elements/node.tsx | 0 .../components/ai-elements/open-in-chat.tsx | 0 .../src/components/ai-elements/panel.tsx | 0 .../src/components/ai-elements/plan.tsx | 0 .../components/ai-elements/prompt-input.tsx | 0 .../src/components/ai-elements/queue.tsx | 0 .../src/components/ai-elements/reasoning.tsx | 0 .../ai-elements/response-content.ts | 0 .../ai-elements/response-node-guards.ts | 0 .../ai-elements/response-renderer-alert.tsx | 0 .../ai-elements/response-renderer-blocks.tsx | 0 .../ai-elements/response-renderer-details.tsx | 0 .../response-renderer-footnotes.tsx | 0 .../ai-elements/response-renderer-image.tsx | 0 .../ai-elements/response-renderer-inline.tsx | 0 .../ai-elements/response-renderer-table.tsx | 0 .../ai-elements/response-renderer.tsx | 0 .../components/ai-elements/response-types.ts | 0 .../src/components/ai-elements/response.tsx | 0 .../src/components/ai-elements/shimmer.tsx | 0 .../src/components/ai-elements/sources.tsx | 0 .../src/components/ai-elements/suggestion.tsx | 0 .../src/components/ai-elements/task.tsx | 0 .../src/components/ai-elements/tool.tsx | 0 .../src/components/ai-elements/toolbar.tsx | 0 .../components/ai-elements/web-preview.tsx | 0 .../src/components/animate-in-view.tsx | 0 .../src/components/auto-skeleton.tsx | 0 .../src/components/coming-soon.tsx | 0 .../src/components/command-menu.tsx | 0 .../src/components/config-drawer.tsx | 0 .../src/components/confirm-dialog.tsx | 0 .../src/components/copy-button.tsx | 0 .../src/components/data-table/README.md | 0 .../components/data-table/core/badge-cell.tsx | 0 .../data-table/core/badge-list-cell.tsx | 0 .../data-table/core/column-header.tsx | 0 .../data-table/core/column-pinning.ts | 0 .../data-table/core/content-sized-columns.ts | 0 .../data-table/core/data-table-colgroup.tsx | 0 .../data-table/core/data-table-header.tsx | 0 .../data-table/core/data-table-row.tsx | 0 .../data-table/core/data-table-view.tsx | 0 .../components/data-table/core/pagination.tsx | 0 .../data-table/core/row-action-menu.tsx | 0 .../data-table/core/table-empty.tsx | 0 .../data-table/core/table-sizing.ts | 0 .../data-table/core/table-skeleton.tsx | 0 .../data-table/core/truncated-cell.tsx | 0 .../src/components/data-table/core/types.ts | 0 .../hooks/use-data-table-view-mode.ts | 0 .../data-table/hooks/use-data-table.ts | 0 .../hooks/use-debounced-column-filter.ts | 0 .../src/components/data-table/index.ts | 0 .../data-table/layout/card-cell-utils.ts | 0 .../data-table/layout/card-grid.tsx | 18 + .../data-table/layout/card-row-content.tsx | 0 .../data-table/layout/data-table-page.tsx | 18 + .../data-table/layout/mobile-card-list.tsx | 18 + .../static/static-data-table-classnames.ts | 0 .../data-table/static/static-data-table.tsx | 0 .../data-table/static/static-row-actions.tsx | 0 .../data-table/toolbar/bulk-actions.tsx | 0 .../data-table/toolbar/faceted-filter.tsx | 0 .../components/data-table/toolbar/toolbar.tsx | 0 .../data-table/toolbar/view-mode-toggle.tsx | 0 .../data-table/toolbar/view-options.tsx | 0 .../src/components/date-picker.tsx | 0 .../src/components/datetime-picker.tsx | 0 web/{default => }/src/components/dialog.tsx | 0 .../src/components/drawer-layout.ts | 0 .../src/components/empty-state.tsx | 0 .../src/components/error-state.tsx | 0 .../src/components/group-badge.tsx | 0 .../src/components/html-content.tsx | 10 +- .../src/components/json-code-editor.tsx | 0 .../src/components/json-editor.tsx | 0 .../src/components/language-switcher.tsx | 0 .../layout/components/app-header.tsx | 0 .../layout/components/app-sidebar.tsx | 0 .../components/authenticated-layout.tsx | 0 .../layout/components/chat-presets-item.tsx | 0 .../components/layout/components/footer.tsx | 0 .../src/components/layout/components/glow.tsx | 0 .../layout/components/header-logo.tsx | 0 .../components/layout/components/header.tsx | 0 .../src/components/layout/components/logo.tsx | 0 .../src/components/layout/components/main.tsx | 0 .../layout/components/mobile-drawer.tsx | 0 .../components/layout/components/mockup.tsx | 0 .../layout/components/nav-group.tsx | 0 .../layout/components/nav-link-item.tsx | 0 .../components/layout/components/navbar.tsx | 0 .../layout/components/page-footer.tsx | 0 .../layout/components/public-header.tsx | 0 .../layout/components/public-layout.tsx | 0 .../layout/components/public-navigation.tsx | 0 .../layout/components/section-page-layout.tsx | 0 .../components/layout/components/section.tsx | 0 .../layout/components/sidebar-view-header.tsx | 0 .../layout/components/system-brand.tsx | 0 .../components/layout/components/top-nav.tsx | 0 .../layout/config/system-settings.config.ts | 0 .../layout/config/top-nav.config.ts | 0 .../src/components/layout/constants.ts | 0 .../src/components/layout/index.ts | 0 .../layout/lib/sidebar-view-registry.ts | 0 .../src/components/layout/lib/url-utils.ts | 0 .../src/components/layout/types.ts | 0 .../src/components/learn-more.tsx | 0 .../src/components/loading-state.tsx | 0 .../src/components/long-text.tsx | 0 .../src/components/masked-value-display.tsx | 0 .../components/model-group-selector-layout.ts | 1 - .../src/components/model-group-selector.tsx | 18 + .../src/components/multi-select.tsx | 4 +- .../src/components/navigation-progress.tsx | 0 .../src/components/notification-popover.tsx | 0 .../src/components/page-transition.tsx | 0 .../src/components/password-input.tsx | 0 .../src/components/profile-dropdown.tsx | 0 .../src/components/provider-badge.tsx | 0 .../src/components/react-icon-by-name.tsx | 0 .../src/components/rich-content.tsx | 5 +- .../risk-acknowledgement-dialog.tsx | 0 web/{default => }/src/components/search.tsx | 0 .../src/components/sign-out-dialog.tsx | 0 .../src/components/skip-to-main.tsx | 0 .../src/components/status-badge.tsx | 18 + web/{default => }/src/components/table-id.tsx | 0 .../src/components/tag-input.tsx | 0 .../src/components/theme-quick-switcher.tsx | 0 .../src/components/theme-switch.tsx | 0 .../components/truncated-text.tsx} | 32 +- .../src/components/turnstile.tsx | 0 .../src/components/ui/accordion.tsx | 0 .../src/components/ui/alert-dialog.tsx | 0 web/{default => }/src/components/ui/alert.tsx | 0 .../src/components/ui/aspect-ratio.tsx | 0 .../src/components/ui/avatar.tsx | 0 web/{default => }/src/components/ui/badge.tsx | 0 .../src/components/ui/breadcrumb.tsx | 0 .../src/components/ui/button-group.tsx | 0 .../src/components/ui/button.tsx | 0 .../src/components/ui/calendar.tsx | 0 web/{default => }/src/components/ui/card.tsx | 0 .../src/components/ui/carousel.tsx | 0 web/{default => }/src/components/ui/chart.tsx | 0 .../src/components/ui/checkbox.tsx | 0 .../src/components/ui/collapsible.tsx | 0 .../src/components/ui/combobox-input.tsx | 0 .../src/components/ui/combobox.tsx | 0 .../src/components/ui/command.tsx | 0 .../src/components/ui/context-menu.tsx | 0 .../src/components/ui/dialog.tsx | 0 .../src/components/ui/direction.tsx | 0 .../src/components/ui/drawer.tsx | 0 .../components/ui/dropdown-menu-events.ts} | 30 +- .../src/components/ui/dropdown-menu.test.tsx | 18 + .../src/components/ui/dropdown-menu.tsx | 0 web/{default => }/src/components/ui/empty.tsx | 0 web/{default => }/src/components/ui/field.tsx | 0 web/{default => }/src/components/ui/form.tsx | 0 .../src/components/ui/hover-card.tsx | 0 .../src/components/ui/icon-badge.tsx | 0 .../src/components/ui/input-group.tsx | 0 .../src/components/ui/input-otp.tsx | 0 web/{default => }/src/components/ui/input.tsx | 0 web/{default => }/src/components/ui/item.tsx | 0 web/{default => }/src/components/ui/kbd.tsx | 0 web/{default => }/src/components/ui/label.tsx | 0 .../src/components/ui/markdown.tsx | 0 .../src/components/ui/menubar.tsx | 0 .../src/components/ui/native-select.tsx | 0 .../src/components/ui/navigation-menu.tsx | 0 .../src/components/ui/pagination.tsx | 0 .../src/components/ui/popover.tsx | 0 .../src/components/ui/progress.tsx | 0 .../src/components/ui/radio-group.tsx | 0 .../src/components/ui/resizable.tsx | 0 .../src/components/ui/scroll-area.tsx | 0 .../src/components/ui/select.tsx | 0 .../src/components/ui/separator.tsx | 0 web/{default => }/src/components/ui/sheet.tsx | 0 .../src/components/ui/sidebar.tsx | 0 .../src/components/ui/skeleton.tsx | 0 .../src/components/ui/slider.tsx | 0 .../src/components/ui/sonner.tsx | 0 .../src/components/ui/spinner.tsx | 0 .../src/components/ui/switch.tsx | 0 web/{default => }/src/components/ui/table.tsx | 0 web/{default => }/src/components/ui/tabs.tsx | 0 .../src/components/ui/textarea.tsx | 0 .../src/components/ui/titled-card.tsx | 0 .../src/components/ui/toggle-group.tsx | 0 .../src/components/ui/toggle.tsx | 0 .../src/components/ui/tooltip.tsx | 0 web/{default => }/src/config/fonts.ts | 0 .../src/context/direction-provider.tsx | 0 .../src/context/font-provider.tsx | 0 .../src/context/layout-provider.tsx | 0 .../src/context/search-provider.tsx | 0 .../context/theme-customization-provider.tsx | 0 .../src/context/theme-provider.tsx | 0 web/{default => }/src/env.d.ts | 4 - web/{default => }/src/features/about/api.ts | 0 .../src/features/about/index.tsx | 0 web/{default => }/src/features/about/types.ts | 0 .../src/features/auth/api.test.ts | 0 web/{default => }/src/features/auth/api.ts | 14 + .../src/features/auth/auth-layout.tsx | 0 .../auth/components/legal-consent.tsx | 0 .../auth/components/oauth-callback-screen.tsx | 0 .../auth/components/oauth-providers.tsx | 73 +- .../auth/components/telegram-login-dialog.tsx | 110 + .../features/auth/components/terms-footer.tsx | 0 .../src/features/auth/constants.ts | 0 .../components/forgot-password-form.tsx | 0 .../features/auth/forgot-password/index.tsx | 0 .../features/auth/hooks/use-auth-redirect.ts | 0 .../auth/hooks/use-email-verification.ts | 0 .../features/auth/hooks/use-oauth-login.ts | 62 +- .../src/features/auth/hooks/use-turnstile.ts | 0 web/{default => }/src/features/auth/index.ts | 1 + .../features/auth/lib/auth-redirect.test.ts | 0 .../src/features/auth/lib/auth-redirect.ts | 0 .../auth/lib/oauth-bind-window.test.ts | 0 .../features/auth/lib/oauth-bind-window.ts | 0 .../src/features/auth/lib/oauth.ts | 0 .../src/features/auth/lib/storage.ts | 0 .../features/auth/lib/telegram-login.test.ts | 67 + web/src/features/auth/lib/telegram-login.ts | 71 + .../src/features/auth/lib/validation.ts | 0 .../features/auth/otp/components/otp-form.tsx | 0 .../src/features/auth/otp/index.tsx | 0 .../src/features/auth/passkey/api.ts | 0 .../passkey/hooks/use-passkey-management.ts | 0 .../src/features/auth/passkey/index.ts | 0 .../src/features/auth/passkey/types.ts | 0 .../auth/reset-password-confirm/index.tsx | 0 .../features/auth/secure-verification/api.ts | 0 .../components/secure-verification-dialog.tsx | 0 .../hooks/use-secure-verification.ts | 0 .../auth/secure-verification/index.ts | 0 .../auth/secure-verification/types.ts | 0 .../sign-in/components/user-auth-form.tsx | 1 + .../src/features/auth/sign-in/index.tsx | 0 .../auth/sign-up/components/sign-up-form.tsx | 0 .../src/features/auth/sign-up/index.tsx | 0 web/{default => }/src/features/auth/types.ts | 2 + .../src/features/channels/api.ts | 0 .../channels/components/channel-card.tsx | 0 .../components/channel-row-actions-context.ts | 0 .../channels/components/channels-columns.tsx | 2 +- .../channels/components/channels-dialogs.tsx | 0 .../components/channels-primary-buttons.tsx | 0 .../channels/components/channels-provider.tsx | 0 .../channels/components/channels-table.tsx | 0 .../components/data-table-bulk-actions.tsx | 0 .../components/data-table-row-actions.tsx | 0 .../components/data-table-tag-row-actions.tsx | 0 .../dialogs/advanced-custom-editor-dialog.tsx | 0 .../dialogs/balance-query-dialog.tsx | 0 .../dialogs/channel-test-dialog.tsx | 0 .../components/dialogs/codex-usage-dialog.tsx | 18 + .../dialogs/copy-channel-dialog.tsx | 0 .../components/dialogs/edit-tag-dialog.tsx | 0 .../dialogs/fetch-models-dialog.tsx | 0 .../missing-models-confirmation-dialog.tsx | 0 .../dialogs/multi-key-manage-dialog.tsx | 0 .../dialogs/multi-key-statistics-card.tsx | 0 .../dialogs/multi-key-table-row-actions.tsx | 0 .../dialogs/ollama-models-dialog.tsx | 0 .../dialogs/param-override-editor-dialog.tsx | 1 - .../dialogs/status-code-risk-dialog.tsx | 0 .../dialogs/tag-batch-edit-dialog.tsx | 0 .../dialogs/upstream-update-dialog.tsx | 0 .../drawers/channel-mutate-drawer.tsx | 0 .../sections/channel-advanced-section.tsx | 18 + .../sections/channel-api-access-section.tsx | 0 .../drawers/sections/channel-auth-section.tsx | 0 .../sections/channel-basic-section.tsx | 0 .../sections/channel-editor-loading-state.tsx | 0 .../sections/channel-models-section.tsx | 0 .../components/drawers/sections/index.ts | 0 .../components/model-mapping-editor.tsx | 0 .../components/numeric-spinner-input.tsx | 0 .../src/features/channels/constants.ts | 0 .../channels/hooks/use-channel-mutate-form.ts | 0 .../hooks/use-channel-upstream-updates.ts | 0 .../src/features/channels/index.tsx | 0 .../features/channels/lib/advanced-custom.ts | 0 .../features/channels/lib/channel-actions.ts | 0 .../channels/lib/channel-form-errors.ts | 0 .../src/features/channels/lib/channel-form.ts | 0 .../channels/lib/channel-type-config.ts | 0 .../features/channels/lib/channel-utils.ts | 0 .../src/features/channels/lib/index.ts | 0 .../channels/lib/model-mapping-validation.ts | 0 .../features/channels/lib/multi-key-utils.ts | 0 .../src/features/channels/lib/ollama-utils.ts | 0 .../channels/lib/status-code-risk-guard.ts | 0 .../channels/lib/upstream-update-utils.ts | 0 .../src/features/channels/types.ts | 0 .../chat/hooks/use-active-chat-key.ts | 0 .../features/chat/hooks/use-chat-presets.ts | 0 .../src/features/chat/lib/chat-links.ts | 0 .../src/features/chat/lib/send-to-fluent.ts | 0 .../src/features/dashboard/api.ts | 2 +- .../dashboard/components/flow/flow-charts.tsx | 0 .../components/flow/flow-node-filter.tsx | 0 .../models/consumption-distribution-chart.tsx | 0 .../components/models/log-stat-cards.tsx | 0 .../components/models/model-charts.tsx | 0 .../models/models-chart-preferences.tsx | 0 .../models/models-filter-dialog.tsx | 0 .../models/performance-overview.tsx | 0 .../overview/announcement-detail-dialog.tsx | 0 .../overview/announcements-panel.tsx | 0 .../components/overview/api-info-item.tsx | 0 .../components/overview/api-info-panel.tsx | 0 .../components/overview/faq-panel.tsx | 0 .../overview/overview-dashboard.tsx | 0 .../overview/performance-health-panel.tsx | 0 .../components/overview/summary-cards.tsx | 0 .../components/overview/uptime-panel.tsx | 0 .../dashboard/components/ui/panel-wrapper.tsx | 0 .../dashboard/components/ui/stat-card.tsx | 0 .../components/users/user-charts.tsx | 0 .../src/features/dashboard/constants.ts | 0 .../dashboard/hooks/use-dashboard-config.tsx | 0 .../dashboard/hooks/use-status-data.ts | 0 .../src/features/dashboard/index.tsx | 0 .../src/features/dashboard/lib/api-info.ts | 0 .../src/features/dashboard/lib/charts.ts | 0 .../src/features/dashboard/lib/filters.ts | 0 .../dashboard/lib/flow-selection.test.ts | 18 + .../features/dashboard/lib/flow-selection.ts | 0 .../src/features/dashboard/lib/flow.test.ts | 18 + .../src/features/dashboard/lib/flow.ts | 0 .../src/features/dashboard/lib/index.ts | 0 .../src/features/dashboard/lib/stats.ts | 0 .../src/features/dashboard/lib/text.ts | 0 .../features/dashboard/section-registry.tsx | 0 .../src/features/dashboard/types.ts | 0 .../src/features/errors/forbidden.tsx | 0 .../src/features/errors/general-error.tsx | 0 .../src/features/errors/maintenance-error.tsx | 0 .../src/features/errors/not-found-error.tsx | 0 .../features/errors/unauthorized-error.tsx | 0 web/{default => }/src/features/home/api.ts | 0 .../home/components/connection-line.tsx | 0 .../features/home/components/feature-item.tsx | 0 .../features/home/components/gateway-card.tsx | 0 .../features/home/components/hero-buttons.tsx | 0 .../home/components/hero-terminal-demo.tsx | 0 .../features/home/components/icon-card.tsx | 0 .../src/features/home/components/index.ts | 0 .../home/components/scrolling-icons.tsx | 0 .../features/home/components/sections/cta.tsx | 0 .../home/components/sections/features.tsx | 0 .../home/components/sections/hero.tsx | 0 .../home/components/sections/how-it-works.tsx | 0 .../home/components/sections/stats.tsx | 0 .../features/home/components/stat-item.tsx | 0 .../src/features/home/constants.ts | 0 .../src/features/home/hooks/index.ts | 0 .../home/hooks/use-home-page-content.ts | 0 web/{default => }/src/features/home/index.tsx | 0 .../src/features/home/lib/icon-mapper.tsx | 0 web/{default => }/src/features/home/types.ts | 0 web/{default => }/src/features/keys/api.ts | 0 .../components/api-key-group-combobox.tsx | 0 .../components/api-key-timestamp-cell.tsx | 0 .../keys/components/api-keys-cells.tsx | 0 .../keys/components/api-keys-columns.tsx | 0 .../components/api-keys-delete-dialog.tsx | 0 .../keys/components/api-keys-dialogs.tsx | 0 .../api-keys-multi-delete-dialog.tsx | 0 .../components/api-keys-mutate-drawer.tsx | 0 .../components/api-keys-primary-buttons.tsx | 0 .../keys/components/api-keys-provider.tsx | 0 .../keys/components/api-keys-table.tsx | 0 .../components/data-table-bulk-actions.tsx | 0 .../components/data-table-row-actions.tsx | 0 .../components/dialogs/cc-switch-dialog.tsx | 0 .../src/features/keys/constants.ts | 0 web/{default => }/src/features/keys/index.tsx | 0 .../src/features/keys/lib/api-key-form.ts | 0 .../src/features/keys/lib/index.ts | 0 web/{default => }/src/features/keys/types.ts | 0 web/{default => }/src/features/legal/api.ts | 0 web/{default => }/src/features/legal/index.ts | 0 .../src/features/legal/legal-document.tsx | 6 +- .../src/features/legal/privacy-policy.tsx | 0 web/{default => }/src/features/legal/types.ts | 0 .../src/features/legal/user-agreement.tsx | 0 web/{default => }/src/features/models/api.ts | 0 .../components/data-table-bulk-actions.tsx | 0 .../components/data-table-row-actions.tsx | 0 .../components/deployment-access-guard.tsx | 0 .../models/components/deployments-columns.tsx | 0 .../models/components/deployments-table.tsx | 0 .../models/components/description-cell.tsx | 0 .../dialogs/create-deployment-drawer.tsx | 0 .../components/dialogs/description-dialog.tsx | 0 .../dialogs/extend-deployment-dialog.tsx | 0 .../dialogs/missing-models-dialog.tsx | 0 .../prefill-group-management-dialog.tsx | 0 .../dialogs/prefill-group-management.tsx | 0 .../dialogs/rename-deployment-dialog.tsx | 0 .../components/dialogs/sync-wizard-dialog.tsx | 0 .../dialogs/update-config-dialog.tsx | 0 .../dialogs/upstream-conflict-dialog.tsx | 0 .../dialogs/vendor-mutate-dialog.tsx | 0 .../dialogs/view-details-dialog.tsx | 18 + .../components/dialogs/view-logs-dialog.tsx | 18 + .../drawers/model-mutate-drawer.tsx | 0 .../drawers/prefill-group-form-drawer.tsx | 0 .../models/components/models-columns.tsx | 0 .../models/components/models-dialogs.tsx | 0 .../components/models-primary-buttons.tsx | 0 .../models/components/models-provider.tsx | 0 .../models/components/models-table.tsx | 6 +- .../models/components/prefill-group-shared.ts | 0 .../src/features/models/constants.ts | 0 .../hooks/use-model-deployment-settings.ts | 0 .../src/features/models/index.tsx | 0 .../features/models/lib/deployments-utils.ts | 0 .../src/features/models/lib/index.ts | 0 .../src/features/models/lib/model-actions.ts | 0 .../src/features/models/lib/model-form.ts | 0 .../src/features/models/lib/model-utils.ts | 0 .../src/features/models/lib/query-keys.ts | 0 .../src/features/models/lib/vendor-actions.ts | 0 .../src/features/models/section-registry.tsx | 0 .../src/features/models/types.ts | 0 .../src/features/performance-metrics/api.ts | 0 .../performance-metrics/lib/format.ts | 0 .../src/features/performance-metrics/types.ts | 0 .../src/features/playground/api.ts | 0 .../components/chat/playground-chat.tsx | 0 .../chat/playground-empty-state.tsx | 0 .../input/playground-input-controls.tsx | 0 .../input/playground-input-tools.tsx | 0 .../components/input/playground-input.tsx | 0 .../input/playground-parameter-panel.tsx | 0 .../message/message-action-button.tsx | 0 .../components/message/message-actions.tsx | 0 .../message/message-error-actions.tsx | 0 .../components/message/message-error.tsx | 18 + .../components/message/message-metadata.tsx | 0 .../message/playground-message-content.tsx | 0 .../message/playground-message-editor.tsx | 0 .../src/features/playground/constants.ts | 0 .../src/features/playground/hooks/index.ts | 0 .../playground/hooks/use-chat-handler.ts | 0 .../hooks/use-message-action-guard.ts | 0 .../hooks/use-playground-conversation.ts | 0 .../hooks/use-playground-options.ts | 18 + .../playground/hooks/use-playground-state.ts | 0 .../hooks/use-stream-request.test.ts | 0 .../playground/hooks/use-stream-request.ts | 0 .../src/features/playground/index.tsx | 0 .../src/features/playground/lib/index.ts | 0 .../lib/input/input-control-utils.ts | 0 .../playground/lib/input/input-tool-utils.ts | 0 .../lib/message/conversation-message-utils.ts | 18 + .../lib/message/message-action-utils.ts | 0 .../lib/message/message-content-utils.ts | 0 .../lib/message/message-editor-utils.ts | 0 .../lib/message/message-error-utils.ts | 0 .../lib/message/message-layout-utils.ts | 0 .../lib/message/message-reasoning-utils.ts | 1 - .../lib/message/message-streaming-utils.ts | 0 .../playground/lib/message/message-styles.ts | 0 .../lib/message/message-timing-utils.ts | 0 .../lib/message/message-update-utils.ts | 0 .../playground/lib/message/message-utils.ts | 0 .../lib/options/playground-option-utils.ts | 0 .../lib/parameters/playground-parameters.ts | 0 .../lib/state/playground-state-utils.ts | 0 .../playground/lib/storage/storage-schema.ts | 0 .../playground/lib/storage/storage.ts | 0 .../lib/streaming/payload-builder.ts | 0 .../lib/streaming/request-error-utils.ts | 0 .../playground/lib/streaming/stream-utils.ts | 0 .../src/features/playground/types.ts | 0 web/{default => }/src/features/pricing/api.ts | 0 .../components/dynamic-pricing-breakdown.tsx | 0 .../pricing/components/empty-state.tsx | 0 .../src/features/pricing/components/index.ts | 0 .../pricing/components/loading-skeleton.tsx | 0 .../components/model-billing-mode-badge.tsx | 0 .../pricing/components/model-card-grid.tsx | 0 .../pricing/components/model-card.tsx | 0 .../pricing/components/model-details-api.tsx | 0 .../pricing/components/model-details-apps.tsx | 0 .../components/model-details-charts.tsx | 0 .../components/model-details-performance.tsx | 0 .../model-details-uptime-sparkline.tsx | 0 .../pricing/components/model-details.tsx | 0 .../pricing/components/model-perf-badge.tsx | 0 .../pricing/components/pricing-columns.tsx | 0 .../pricing/components/pricing-sidebar.tsx | 0 .../pricing/components/pricing-table.tsx | 0 .../pricing/components/pricing-toolbar.tsx | 0 .../pricing/components/search-bar.tsx | 0 .../src/features/pricing/constants.ts | 0 .../src/features/pricing/hooks/index.ts | 0 .../src/features/pricing/hooks/use-filters.ts | 0 .../pricing/hooks/use-pricing-data.ts | 0 .../src/features/pricing/index.tsx | 0 .../src/features/pricing/lib/billing-expr.ts | 4 +- .../src/features/pricing/lib/dynamic-price.ts | 0 .../src/features/pricing/lib/filters.ts | 0 .../src/features/pricing/lib/index.ts | 0 .../src/features/pricing/lib/mock-stats.ts | 0 .../src/features/pricing/lib/model-helpers.ts | 4 +- .../src/features/pricing/lib/price.ts | 0 .../src/features/pricing/lib/seed.ts | 0 .../src/features/pricing/lib/tier-expr.ts | 0 .../src/features/pricing/types.ts | 0 web/{default => }/src/features/profile/api.ts | 6 +- .../components/checkin-calendar-card.tsx | 0 .../dialogs/access-token-dialog.tsx | 0 .../dialogs/change-password-dialog.tsx | 0 .../dialogs/delete-account-dialog.tsx | 0 .../components/dialogs/email-bind-dialog.tsx | 0 .../dialogs/telegram-bind-dialog.tsx | 0 .../dialogs/two-fa-backup-dialog.tsx | 0 .../dialogs/two-fa-disable-dialog.tsx | 0 .../dialogs/two-fa-setup-dialog.tsx | 0 .../components/dialogs/wechat-bind-dialog.tsx | 139 + .../components/language-preferences-card.tsx | 0 .../components/login-session-dialogs.tsx | 0 .../profile/components/login-session-item.tsx | 0 .../components/login-session-utils.test.ts | 0 .../profile/components/login-session-utils.ts | 0 .../components/login-sessions-card.tsx | 0 .../profile/components/passkey-card.tsx | 0 .../profile/components/profile-header.tsx | 0 .../components/profile-security-card.tsx | 0 .../components/profile-settings-card.tsx | 0 .../components/sidebar-modules-card.tsx | 0 .../components/tabs/account-bindings-tab.tsx | 3 + .../components/tabs/notification-tab.tsx | 0 .../profile/components/two-fa-card.tsx | 0 .../src/features/profile/constants.ts | 0 .../src/features/profile/hooks/index.ts | 0 .../profile/hooks/use-access-token.ts | 0 .../src/features/profile/hooks/use-profile.ts | 0 .../src/features/profile/hooks/use-two-fa.ts | 0 .../src/features/profile/index.tsx | 0 .../src/features/profile/lib/format.ts | 0 .../src/features/profile/lib/index.ts | 0 .../src/features/profile/types.ts | 0 .../src/features/rankings/api.ts | 0 .../rankings/components/entity-links.tsx | 0 .../rankings/components/growth-text.tsx | 0 .../src/features/rankings/components/index.ts | 0 .../components/market-share-section.tsx | 0 .../rankings/components/model-leaderboard.tsx | 0 .../rankings/components/models-section.tsx | 0 .../rankings/components/pulse-section.tsx | 0 .../rankings/components/rankings-hero.tsx | 0 .../features/rankings/hooks/use-rankings.ts | 0 .../src/features/rankings/index.tsx | 0 .../src/features/rankings/lib/format.ts | 0 .../src/features/rankings/lib/index.ts | 0 .../src/features/rankings/types.ts | 0 .../src/features/redemption-codes/api.ts | 0 .../components/data-table-bulk-actions.tsx | 0 .../components/data-table-row-actions.tsx | 0 .../components/redemptions-columns.tsx | 0 .../components/redemptions-delete-dialog.tsx | 0 .../components/redemptions-dialogs.tsx | 0 .../components/redemptions-mobile-list.tsx | 0 .../components/redemptions-mutate-drawer.tsx | 0 .../redemptions-primary-buttons.tsx | 0 .../components/redemptions-provider.tsx | 0 .../components/redemptions-table.tsx | 0 .../features/redemption-codes/constants.ts | 0 .../src/features/redemption-codes/index.tsx | 0 .../features/redemption-codes/lib/index.ts | 0 .../redemption-codes/lib/redemption-form.ts | 0 .../features/redemption-codes/lib/utils.ts | 0 .../src/features/redemption-codes/types.ts | 0 web/{default => }/src/features/setup/api.ts | 0 .../features/setup/components/admin-step.tsx | 0 .../setup/components/complete-step.tsx | 0 .../setup/components/database-step.tsx | 0 .../setup/components/step-navigation.tsx | 0 .../setup/components/usage-mode-step.tsx | 0 web/{default => }/src/features/setup/index.ts | 0 .../src/features/setup/setup-wizard.tsx | 0 web/{default => }/src/features/setup/types.ts | 0 .../src/features/subscriptions/api.ts | 0 .../components/data-table-row-actions.tsx | 0 .../dialogs/reset-subscriptions-dialog.tsx | 0 .../dialogs/subscription-purchase-dialog.tsx | 0 .../dialogs/toggle-status-dialog.tsx | 0 .../dialogs/user-subscriptions-dialog.tsx | 5 +- .../components/subscriptions-columns.tsx | 0 .../components/subscriptions-dialogs.tsx | 0 .../subscriptions-mutate-drawer.tsx | 0 .../subscriptions-primary-buttons.tsx | 0 .../components/subscriptions-provider.tsx | 0 .../components/subscriptions-table.tsx | 0 .../src/features/subscriptions/constants.ts | 0 .../src/features/subscriptions/index.tsx | 0 .../src/features/subscriptions/lib/format.ts | 0 .../src/features/subscriptions/lib/index.ts | 0 .../features/subscriptions/lib/plan-form.ts | 0 .../src/features/subscriptions/types.ts | 0 .../src/features/system-info/api.ts | 0 .../components/system-instances-panel.tsx | 0 .../components/system-tasks-panel.tsx | 0 .../src/features/system-info/index.tsx | 0 .../src/features/system-info/types.ts | 0 .../src/features/system-settings/api.ts | 0 .../auth/basic-auth-section.tsx | 0 .../auth/bot-protection-section.tsx | 0 .../system-settings/auth/custom-oauth/api.ts | 0 .../components/discovery-button.tsx | 0 .../components/preset-selector.tsx | 0 .../components/provider-form-dialog.tsx | 0 .../components/provider-table.tsx | 0 .../custom-oauth/custom-oauth-section.tsx | 0 .../hooks/use-custom-oauth-mutations.ts | 0 .../hooks/use-custom-oauth-providers.ts | 0 .../auth/custom-oauth/types.ts | 0 .../features/system-settings/auth/index.tsx | 0 .../auth/oauth-callback-url.ts | 1 - .../system-settings/auth/oauth-section.tsx | 0 .../system-settings/auth/passkey-section.tsx | 0 .../system-settings/auth/section-registry.tsx | 0 .../system-settings/billing/index.tsx | 0 .../billing/section-registry.tsx | 0 .../components/form-dirty-indicator.tsx | 0 .../components/form-navigation-guard.tsx | 0 .../components/settings-accordion.tsx | 0 .../components/settings-card.tsx | 0 .../components/settings-form-layout.tsx | 0 .../components/settings-page-context.tsx | 0 .../components/settings-page.tsx | 0 .../components/settings-section.tsx | 0 .../content/announcements-section.tsx | 0 .../content/api-info-section.tsx | 0 .../system-settings/content/chat-dialog.tsx | 0 .../content/chat-settings-section.tsx | 0 .../content/chat-settings-visual-editor.tsx | 0 .../content/dashboard-section.tsx | 0 .../content/drawing-settings-section.tsx | 0 .../system-settings/content/faq-section.tsx | 0 .../system-settings/content/index.tsx | 0 .../content/json-toggle-section.tsx | 0 .../content/section-registry.tsx | 0 .../content/uptime-kuma-section.tsx | 0 .../features/system-settings/content/utils.ts | 0 .../general/channel-affinity/api.ts | 0 .../channel-affinity/cache-stats-dialog.tsx | 0 .../general/channel-affinity/constants.ts | 0 .../general/channel-affinity/index.tsx | 0 .../channel-affinity/rule-editor-dialog.tsx | 0 .../general/channel-affinity/types.ts | 0 .../general/checkin-settings-section.tsx | 0 .../general/pricing-section.tsx | 0 .../general/quota-settings-section.tsx | 0 .../general/system-behavior-section.tsx | 0 .../general/system-info-section.tsx | 101 +- .../hooks/use-accordion-state.ts | 0 .../hooks/use-form-dirty-guard.ts | 0 .../system-settings/hooks/use-reset-form.ts | 0 .../hooks/use-safe-json-state.ts | 0 .../hooks/use-settings-form.ts | 0 .../hooks/use-system-options.ts | 0 .../hooks/use-update-option.ts | 1 - .../src/features/system-settings/index.tsx | 0 .../integrations/amount-discount-dialog.tsx | 0 .../amount-discount-visual-editor.tsx | 0 .../amount-options-visual-editor.tsx | 0 .../integrations/creem-product-dialog.tsx | 0 .../creem-products-visual-editor.tsx | 0 .../integrations/email-settings-section.tsx | 0 .../ionet-deployment-settings-section.tsx | 0 .../monitoring-settings-section.tsx | 0 .../integrations/payment-method-dialog.tsx | 0 .../payment-methods-visual-editor.tsx | 0 .../integrations/payment-settings-section.tsx | 0 .../system-settings/integrations/utils.ts | 0 .../integrations/waffo-pancake-api.ts | 0 .../waffo-pancake-settings-section.tsx | 2 +- .../integrations/waffo-settings-section.tsx | 2 +- .../integrations/worker-settings-section.tsx | 0 .../system-settings/maintenance/config.ts | 0 .../maintenance/header-navigation-section.tsx | 0 .../maintenance/log-settings-section.tsx | 0 .../maintenance/notice-section.tsx | 0 .../maintenance/performance-section.tsx | 0 .../maintenance/sidebar-modules-section.tsx | 0 .../maintenance/update-checker-section.tsx | 0 .../models/channel-selector-dialog.tsx | 0 .../models/claude-settings-card.tsx | 0 .../models/conflict-confirm-dialog.tsx | 0 .../system-settings/models/constants.ts | 4 +- .../models/gemini-settings-card.tsx | 0 .../models/global-settings-card.tsx | 0 .../models/grok-settings-card.tsx | 0 .../models/group-ratio-form.tsx | 28 +- .../models/group-ratio-visual-editor.tsx | 13 +- .../models/group-special-usable-editor.tsx | 0 .../features/system-settings/models/index.tsx | 0 .../models/model-pricing-core.ts | 0 .../models/model-pricing-inputs.tsx | 0 .../models/model-pricing-sheet.tsx | 0 .../models/model-pricing-snapshots.ts | 0 .../models/model-ratio-form.tsx | 0 .../models/model-ratio-table-columns.tsx | 0 .../models/model-ratio-visual-editor.tsx | 0 .../system-settings/models/pricing-format.ts | 0 .../models/ratio-settings-card.tsx | 0 .../models/routing-reliability-section.tsx | 0 .../models/section-registry.tsx | 0 .../models/tiered-pricing-editor.tsx | 2 +- .../models/tool-price-settings.tsx | 0 .../models/upstream-ratio-sync-columns.tsx | 0 .../models/upstream-ratio-sync-helpers.ts | 0 .../models/upstream-ratio-sync-table.tsx | 0 .../models/upstream-ratio-sync.tsx | 0 .../features/system-settings/models/utils.ts | 0 .../system-settings/operations/index.tsx | 0 .../operations/section-registry.tsx | 0 .../request-limits/rate-limit-dialog.tsx | 0 .../request-limits/rate-limit-section.tsx | 0 .../rate-limit-visual-editor.tsx | 0 .../sensitive-words-section.tsx | 0 .../request-limits/ssrf-section.tsx | 0 .../request-limits/token-limit-section.tsx | 0 .../system-settings/security/index.tsx | 0 .../security/section-registry.tsx | 0 .../features/system-settings/site/index.tsx | 1 - .../system-settings/site/section-registry.tsx | 3 - .../src/features/system-settings/types.ts | 1 - .../system-settings/utils/json-parser.ts | 0 .../system-settings/utils/json-validators.ts | 0 .../system-settings/utils/numeric-field.ts | 4 +- .../system-settings/utils/route-config.ts | 0 .../system-settings/utils/section-registry.ts | 0 .../src/features/usage-logs/api.ts | 0 .../components/columns/column-helpers.tsx | 0 .../columns/common-logs-columns.tsx | 0 .../columns/drawing-logs-columns.tsx | 0 .../components/columns/task-logs-columns.tsx | 0 .../components/common-logs-filter-bar.tsx | 0 .../components/common-logs-header-actions.tsx | 0 .../components/common-logs-stats.tsx | 0 .../compact-date-time-range-picker.tsx | 0 .../dialogs/audio-preview-dialog.tsx | 0 .../components/dialogs/details-dialog.tsx | 24 +- .../components/dialogs/fail-reason-dialog.tsx | 0 .../components/dialogs/image-dialog.tsx | 0 .../components/dialogs/prompt-dialog.tsx | 0 .../components/dialogs/user-info-dialog.tsx | 0 .../components/logs-filter-toolbar.tsx | 8 +- .../usage-logs/components/model-badge.tsx | 0 .../components/task-logs-filter-bar.tsx | 0 .../components/timing-metrics-cell.tsx | 12 +- .../components/usage-logs-mobile-card.tsx | 0 .../components/usage-logs-provider.tsx | 0 .../components/usage-logs-table.tsx | 0 .../src/features/usage-logs/constants.ts | 0 .../src/features/usage-logs/data/schema.ts | 0 .../src/features/usage-logs/index.tsx | 0 .../src/features/usage-logs/lib/columns.ts | 0 .../src/features/usage-logs/lib/filter.ts | 0 .../src/features/usage-logs/lib/format.ts | 5 +- .../src/features/usage-logs/lib/index.ts | 0 .../src/features/usage-logs/lib/mappers.ts | 0 .../src/features/usage-logs/lib/status.ts | 0 .../src/features/usage-logs/lib/utils.ts | 0 .../features/usage-logs/section-registry.tsx | 0 .../src/features/usage-logs/types.ts | 0 web/{default => }/src/features/users/api.ts | 0 .../components/data-table-bulk-actions.tsx | 0 .../components/data-table-row-actions.tsx | 0 .../dialogs/user-binding-dialog.tsx | 0 .../users/components/user-quota-cell.tsx | 6 +- .../users/components/user-quota-dialog.tsx | 0 .../users/components/users-columns.tsx | 0 .../users/components/users-delete-dialog.tsx | 0 .../users/components/users-mutate-drawer.tsx | 0 .../components/users-primary-buttons.tsx | 0 .../users/components/users-provider.tsx | 0 .../features/users/components/users-table.tsx | 0 .../src/features/users/constants.ts | 0 .../src/features/users/index.tsx | 0 .../src/features/users/lib/index.ts | 0 .../src/features/users/lib/user-actions.ts | 0 .../src/features/users/lib/user-form.ts | 0 web/{default => }/src/features/users/types.ts | 0 web/{default => }/src/features/wallet/api.ts | 12 + .../components/affiliate-rewards-card.tsx | 0 .../components/creem-products-section.tsx | 0 .../dialogs/billing-history-dialog.tsx | 0 .../dialogs/creem-confirm-dialog.tsx | 0 .../dialogs/payment-confirm-dialog.tsx | 0 .../components/dialogs/transfer-dialog.tsx | 4 +- .../wallet/components/recharge-form-card.tsx | 0 .../components/subscription-plans-card.tsx | 0 .../wallet/components/wallet-stats-card.tsx | 0 .../src/features/wallet/constants.ts | 0 .../src/features/wallet/hooks/index.ts | 0 .../features/wallet/hooks/use-affiliate.ts | 0 .../wallet/hooks/use-billing-history.ts | 0 .../wallet/hooks/use-creem-payment.ts | 0 .../features/wallet/hooks/use-payment.test.ts | 50 + .../src/features/wallet/hooks/use-payment.ts | 69 +- .../features/wallet/hooks/use-redemption.ts | 0 .../features/wallet/hooks/use-topup-info.ts | 0 .../wallet/hooks/use-waffo-pancake-payment.ts | 0 .../wallet/hooks/use-waffo-payment.ts | 2 +- .../src/features/wallet/index.tsx | 41 +- .../src/features/wallet/lib/affiliate.ts | 0 .../src/features/wallet/lib/billing.ts | 0 .../src/features/wallet/lib/format.ts | 0 .../src/features/wallet/lib/index.ts | 0 web/src/features/wallet/lib/payment.test.ts | 86 + .../src/features/wallet/lib/payment.ts | 39 +- .../src/features/wallet/lib/ui.tsx | 0 .../src/features/wallet/types.ts | 0 web/{default => }/src/hooks/index.ts | 0 web/{default => }/src/hooks/use-admin.ts | 0 .../src/hooks/use-copy-to-clipboard.ts | 0 web/{default => }/src/hooks/use-countdown.ts | 0 web/{default => }/src/hooks/use-debounce.ts | 0 web/{default => }/src/hooks/use-dialog.ts | 0 .../src/hooks/use-hidden-click-unlock.ts | 0 .../src/hooks/use-media-query.ts | 0 .../src/hooks/use-minimum-loading-time.ts | 0 web/{default => }/src/hooks/use-mobile.ts | 0 web/{default => }/src/hooks/use-mobile.tsx | 0 .../src/hooks/use-notifications.ts | 0 .../src/hooks/use-sidebar-config.ts | 0 .../src/hooks/use-sidebar-data.ts | 0 .../src/hooks/use-sidebar-view.ts | 0 web/{default => }/src/hooks/use-status.ts | 0 .../src/hooks/use-system-config.ts | 0 .../src/hooks/use-table-compact-mode.ts | 0 .../src/hooks/use-table-url-state.ts | 4 +- .../src/hooks/use-top-nav-links.ts | 0 .../src/hooks/use-user-display.ts | 0 web/{default => }/src/i18n/config.ts | 4 +- web/{default => }/src/i18n/languages.ts | 12 +- .../i18n/locales/_reports/_sync-report.json | 0 web/{default => }/src/i18n/locales/en.json | 9 - web/{default => }/src/i18n/locales/fr.json | 9 - web/{default => }/src/i18n/locales/ja.json | 9 - web/{default => }/src/i18n/locales/ru.json | 9 - web/{default => }/src/i18n/locales/vi.json | 11 +- web/{default => }/src/i18n/locales/zh-TW.json | 9 - web/{default => }/src/i18n/locales/zh.json | 9 - web/{default => }/src/i18n/static-keys.ts | 0 .../src/lib/admin-permissions.ts | 18 + web/{default => }/src/lib/api.ts | 0 .../src/lib/auth-session-sync.ts | 0 .../src/lib/auth-session.test.ts | 0 web/{default => }/src/lib/auth-session.ts | 0 web/{default => }/src/lib/avatar.ts | 0 web/{default => }/src/lib/build-metadata.ts | 1 - .../src/lib/channel-connection-info.ts | 1 - web/{default => }/src/lib/colors.ts | 0 web/{default => }/src/lib/constants.ts | 0 web/{default => }/src/lib/content-format.ts | 0 web/{default => }/src/lib/cookies.ts | 0 .../src/lib/copy-to-clipboard.ts | 0 web/{default => }/src/lib/currency.ts | 0 web/{default => }/src/lib/dayjs.ts | 0 web/{default => }/src/lib/dom-utils.ts | 0 web/{default => }/src/lib/format.ts | 0 web/{default => }/src/lib/frontend-cache.ts | 1 - .../src/lib/handle-server-error.ts | 0 web/{default => }/src/lib/http-client.ts | 0 .../src/lib/http-status-code-rules.ts | 0 web/src/lib/legacy-route.test.ts | 97 + web/src/lib/legacy-route.ts | 104 + web/{default => }/src/lib/lobe-icon.tsx | 0 web/{default => }/src/lib/motion.ts | 0 web/{default => }/src/lib/nav-modules.ts | 0 web/{default => }/src/lib/oauth.ts | 0 web/{default => }/src/lib/passkey.ts | 0 web/{default => }/src/lib/roles.ts | 0 .../src/lib/secure-verification.ts | 0 .../src/lib/server-error-message.test.ts | 0 .../src/lib/server-error-message.ts | 0 .../src/lib/show-submitted-data.tsx | 0 .../src/lib/theme-customization.ts | 0 web/{default => }/src/lib/theme-radius.ts | 0 web/{default => }/src/lib/time.ts | 0 web/{default => }/src/lib/use-chart-theme.ts | 0 .../src/lib/use-controllable-state.ts | 0 web/{default => }/src/lib/utils.ts | 0 web/{default => }/src/lib/vchart.ts | 0 web/{default => }/src/main.tsx | 0 web/{default => }/src/routeTree.gen.ts | 1110 +++-- .../src/routes/(auth)/forgot-password.tsx | 0 web/{default => }/src/routes/(auth)/oauth.tsx | 0 web/{default => }/src/routes/(auth)/otp.tsx | 0 .../src/routes/(auth)/register.tsx | 0 web/{default => }/src/routes/(auth)/reset.tsx | 0 web/{default => }/src/routes/(auth)/route.tsx | 0 .../src/routes/(auth)/sign-in.tsx | 0 .../src/routes/(auth)/sign-up.tsx | 0 .../src/routes/(auth)/user/reset.tsx | 0 web/{default => }/src/routes/(errors)/401.tsx | 0 web/{default => }/src/routes/(errors)/403.tsx | 0 web/{default => }/src/routes/(errors)/404.tsx | 0 web/{default => }/src/routes/(errors)/500.tsx | 0 web/{default => }/src/routes/(errors)/503.tsx | 0 web/{default => }/src/routes/__root.tsx | 6 + .../routes/_authenticated/channels/index.tsx | 0 .../routes/_authenticated/chat/$chatId.tsx | 0 .../src/routes/_authenticated/chat2link.tsx | 0 .../_authenticated/dashboard/$section.tsx | 0 .../routes/_authenticated/dashboard/index.tsx | 0 .../routes/_authenticated/errors/$error.tsx | 0 .../src/routes/_authenticated/keys/index.tsx | 0 .../routes/_authenticated/models/$section.tsx | 0 .../routes/_authenticated/models/index.tsx | 0 .../_authenticated/playground/index.tsx | 0 .../routes/_authenticated/profile/index.tsx | 0 .../_authenticated/redemption-codes/index.tsx | 0 .../src/routes/_authenticated/route.tsx | 0 .../_authenticated/subscriptions/index.tsx | 0 .../_authenticated/system-info/index.tsx | 0 .../system-settings/auth/$section.tsx | 0 .../system-settings/auth/index.tsx | 0 .../system-settings/billing/$section.tsx | 0 .../system-settings/billing/index.tsx | 0 .../system-settings/content/$section.tsx | 0 .../system-settings/content/index.tsx | 0 .../_authenticated/system-settings/index.tsx | 0 .../system-settings/models/$section.tsx | 0 .../system-settings/models/index.tsx | 0 .../system-settings/operations/$section.tsx | 0 .../system-settings/operations/index.tsx | 0 .../_authenticated/system-settings/route.tsx | 0 .../system-settings/security/$section.tsx | 0 .../system-settings/security/index.tsx | 0 .../system-settings/site/$section.tsx | 0 .../system-settings/site/index.tsx | 0 .../_authenticated/usage-logs/$section.tsx | 0 .../_authenticated/usage-logs/index.tsx | 0 .../src/routes/_authenticated/users/index.tsx | 0 .../routes/_authenticated/wallet/index.tsx | 0 web/{default => }/src/routes/about/index.tsx | 0 web/{default => }/src/routes/index.tsx | 0 .../src/routes/oauth/$provider.tsx | 0 .../src/routes/pricing/$modelId/index.tsx | 0 .../src/routes/pricing/index.tsx | 0 .../src/routes/privacy-policy.tsx | 0 .../src/routes/rankings/index.tsx | 0 web/{default => }/src/routes/setup/index.tsx | 0 .../src/routes/user-agreement.tsx | 0 web/{default => }/src/stores/auth-store.ts | 0 .../src/stores/notification-store.ts | 0 .../src/stores/system-config-store.ts | 0 web/{default => }/src/styles/index.css | 0 .../src/styles/theme-presets.css | 4 +- web/{default => }/src/styles/theme.css | 12 +- web/{default => }/src/tanstack-table.d.ts | 0 web/{default => }/tsconfig.app.json | 0 web/{default => }/tsconfig.json | 0 web/{default => }/tsconfig.node.json | 0 1536 files changed, 3209 insertions(+), 143383 deletions(-) delete mode 100644 .agents/skills/classic-to-default-sync/SKILL.md delete mode 100644 controller/console_migrate.go create mode 100644 controller/return_path_test.go create mode 100644 controller/theme_compat_test.go create mode 100644 model/frontend_option_migration.go create mode 100644 model/frontend_option_migration_test.go create mode 100644 router/retired_frontend_routes_test.go create mode 100644 service/return_path_test.go delete mode 100644 setting/system_setting/theme.go rename web/{default => }/.gitignore (100%) rename web/{default => }/.node-version (100%) rename web/{default => }/.npmrc (100%) rename web/{default => }/.oxfmtrc.json (100%) rename web/{default => }/.oxlintrc.json (100%) rename web/{default => }/AGENTS.md (99%) delete mode 100644 web/classic/.eslintrc.cjs delete mode 100644 web/classic/.gitignore delete mode 100644 web/classic/.prettierrc.mjs delete mode 100644 web/classic/i18next.config.js delete mode 100644 web/classic/index.html delete mode 100644 web/classic/jsconfig.json delete mode 100644 web/classic/package.json delete mode 100644 web/classic/postcss.config.js delete mode 100644 web/classic/public/azure_model_name.png delete mode 100644 web/classic/public/cover-4.webp delete mode 100644 web/classic/public/ratio.png delete mode 100644 web/classic/public/robots.txt delete mode 100644 web/classic/rsbuild.config.ts delete mode 100644 web/classic/src/App.jsx delete mode 100644 web/classic/src/components/auth/LoginForm.jsx delete mode 100644 web/classic/src/components/auth/OAuth2Callback.jsx delete mode 100644 web/classic/src/components/auth/PasswordResetConfirm.jsx delete mode 100644 web/classic/src/components/auth/PasswordResetForm.jsx delete mode 100644 web/classic/src/components/auth/RegisterForm.jsx delete mode 100644 web/classic/src/components/auth/TwoFAVerification.jsx delete mode 100644 web/classic/src/components/common/DocumentRenderer/index.jsx delete mode 100644 web/classic/src/components/common/ErrorBoundary.jsx delete mode 100644 web/classic/src/components/common/examples/ChannelKeyViewExample.jsx delete mode 100644 web/classic/src/components/common/logo/LinuxDoIcon.jsx delete mode 100644 web/classic/src/components/common/logo/OIDCIcon.jsx delete mode 100644 web/classic/src/components/common/logo/WeChatIcon.jsx delete mode 100644 web/classic/src/components/common/markdown/MarkdownRenderer.jsx delete mode 100644 web/classic/src/components/common/markdown/markdown.css delete mode 100644 web/classic/src/components/common/modals/RiskAcknowledgementModal.jsx delete mode 100644 web/classic/src/components/common/modals/SecureVerificationModal.jsx delete mode 100644 web/classic/src/components/common/modals/TwoFactorAuthModal.jsx delete mode 100644 web/classic/src/components/common/ui/CardPro.jsx delete mode 100644 web/classic/src/components/common/ui/CardTable.jsx delete mode 100644 web/classic/src/components/common/ui/ChannelKeyDisplay.jsx delete mode 100644 web/classic/src/components/common/ui/CompactModeToggle.jsx delete mode 100644 web/classic/src/components/common/ui/JSONEditor.jsx delete mode 100644 web/classic/src/components/common/ui/Loading.jsx delete mode 100644 web/classic/src/components/common/ui/RenderUtils.jsx delete mode 100644 web/classic/src/components/common/ui/ScrollableContainer.jsx delete mode 100644 web/classic/src/components/common/ui/SelectableButtonGroup.jsx delete mode 100644 web/classic/src/components/dashboard/AnnouncementsPanel.jsx delete mode 100644 web/classic/src/components/dashboard/ApiInfoPanel.jsx delete mode 100644 web/classic/src/components/dashboard/ChartsPanel.jsx delete mode 100644 web/classic/src/components/dashboard/DashboardHeader.jsx delete mode 100644 web/classic/src/components/dashboard/FaqPanel.jsx delete mode 100644 web/classic/src/components/dashboard/StatsCards.jsx delete mode 100644 web/classic/src/components/dashboard/UptimePanel.jsx delete mode 100644 web/classic/src/components/dashboard/index.jsx delete mode 100644 web/classic/src/components/dashboard/modals/SearchModal.jsx delete mode 100644 web/classic/src/components/layout/ClassicFrontendDeprecationBanner.jsx delete mode 100644 web/classic/src/components/layout/Footer.jsx delete mode 100644 web/classic/src/components/layout/NoticeModal.jsx delete mode 100644 web/classic/src/components/layout/PageLayout.jsx delete mode 100644 web/classic/src/components/layout/SetupCheck.js delete mode 100644 web/classic/src/components/layout/SiderBar.jsx delete mode 100644 web/classic/src/components/layout/components/SkeletonWrapper.jsx delete mode 100644 web/classic/src/components/layout/headerbar/ActionButtons.jsx delete mode 100644 web/classic/src/components/layout/headerbar/HeaderLogo.jsx delete mode 100644 web/classic/src/components/layout/headerbar/LanguageSelector.jsx delete mode 100644 web/classic/src/components/layout/headerbar/MobileMenuButton.jsx delete mode 100644 web/classic/src/components/layout/headerbar/Navigation.jsx delete mode 100644 web/classic/src/components/layout/headerbar/NewYearButton.jsx delete mode 100644 web/classic/src/components/layout/headerbar/NotificationButton.jsx delete mode 100644 web/classic/src/components/layout/headerbar/ThemeToggle.jsx delete mode 100644 web/classic/src/components/layout/headerbar/UserArea.jsx delete mode 100644 web/classic/src/components/layout/headerbar/index.jsx delete mode 100644 web/classic/src/components/model-deployments/DeploymentAccessGuard.jsx delete mode 100644 web/classic/src/components/playground/ChatArea.jsx delete mode 100644 web/classic/src/components/playground/CodeViewer.jsx delete mode 100644 web/classic/src/components/playground/ConfigManager.jsx delete mode 100644 web/classic/src/components/playground/CustomInputRender.jsx delete mode 100644 web/classic/src/components/playground/CustomRequestEditor.jsx delete mode 100644 web/classic/src/components/playground/DebugPanel.jsx delete mode 100644 web/classic/src/components/playground/FloatingButtons.jsx delete mode 100644 web/classic/src/components/playground/ImageUrlInput.jsx delete mode 100644 web/classic/src/components/playground/MessageActions.jsx delete mode 100644 web/classic/src/components/playground/MessageContent.jsx delete mode 100644 web/classic/src/components/playground/OptimizedComponents.js delete mode 100644 web/classic/src/components/playground/ParameterControl.jsx delete mode 100644 web/classic/src/components/playground/SSEViewer.jsx delete mode 100644 web/classic/src/components/playground/SettingsPanel.jsx delete mode 100644 web/classic/src/components/playground/ThinkingContent.jsx delete mode 100644 web/classic/src/components/playground/configStorage.js delete mode 100644 web/classic/src/components/playground/index.js delete mode 100644 web/classic/src/components/settings/ChannelSelectorModal.jsx delete mode 100644 web/classic/src/components/settings/ChatsSetting.jsx delete mode 100644 web/classic/src/components/settings/CustomOAuthSetting.jsx delete mode 100644 web/classic/src/components/settings/DashboardSetting.jsx delete mode 100644 web/classic/src/components/settings/DrawingSetting.jsx delete mode 100644 web/classic/src/components/settings/HttpStatusCodeRulesInput.jsx delete mode 100644 web/classic/src/components/settings/ModelDeploymentSetting.jsx delete mode 100644 web/classic/src/components/settings/ModelSetting.jsx delete mode 100644 web/classic/src/components/settings/OperationSetting.jsx delete mode 100644 web/classic/src/components/settings/OtherSetting.jsx delete mode 100644 web/classic/src/components/settings/PaymentSetting.jsx delete mode 100644 web/classic/src/components/settings/PerformanceSetting.jsx delete mode 100644 web/classic/src/components/settings/PersonalSetting.jsx delete mode 100644 web/classic/src/components/settings/RateLimitSetting.jsx delete mode 100644 web/classic/src/components/settings/RatioSetting.jsx delete mode 100644 web/classic/src/components/settings/SystemSetting.jsx delete mode 100644 web/classic/src/components/settings/personal/cards/AccountManagement.jsx delete mode 100644 web/classic/src/components/settings/personal/cards/CheckinCalendar.jsx delete mode 100644 web/classic/src/components/settings/personal/cards/ModelsList.jsx delete mode 100644 web/classic/src/components/settings/personal/cards/NotificationSettings.jsx delete mode 100644 web/classic/src/components/settings/personal/cards/PreferencesSettings.jsx delete mode 100644 web/classic/src/components/settings/personal/components/TwoFASetting.jsx delete mode 100644 web/classic/src/components/settings/personal/components/UserInfoHeader.jsx delete mode 100644 web/classic/src/components/settings/personal/modals/AccountDeleteModal.jsx delete mode 100644 web/classic/src/components/settings/personal/modals/ChangePasswordModal.jsx delete mode 100644 web/classic/src/components/settings/personal/modals/EmailBindModal.jsx delete mode 100644 web/classic/src/components/settings/personal/modals/WeChatBindModal.jsx delete mode 100644 web/classic/src/components/setup/SetupWizard.jsx delete mode 100644 web/classic/src/components/setup/components/StepNavigation.jsx delete mode 100644 web/classic/src/components/setup/components/steps/AdminStep.jsx delete mode 100644 web/classic/src/components/setup/components/steps/CompleteStep.jsx delete mode 100644 web/classic/src/components/setup/components/steps/DatabaseStep.jsx delete mode 100644 web/classic/src/components/setup/components/steps/UsageModeStep.jsx delete mode 100644 web/classic/src/components/setup/index.jsx delete mode 100644 web/classic/src/components/table/channels/ChannelsActions.jsx delete mode 100644 web/classic/src/components/table/channels/ChannelsColumnDefs.jsx delete mode 100644 web/classic/src/components/table/channels/ChannelsFilters.jsx delete mode 100644 web/classic/src/components/table/channels/ChannelsTable.jsx delete mode 100644 web/classic/src/components/table/channels/ChannelsTabs.jsx delete mode 100644 web/classic/src/components/table/channels/index.jsx delete mode 100644 web/classic/src/components/table/channels/modals/BatchTagModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/ChannelUpstreamUpdateModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/CodexUsageModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/ColumnSelectorModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/EditChannelModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/EditTagModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/ModelSelectModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/ModelTestModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/MultiKeyManageModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/OllamaModelModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/ParamOverrideEditorModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/SingleModelSelectModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/StatusCodeRiskGuardModal.jsx delete mode 100644 web/classic/src/components/table/channels/modals/statusCodeRiskGuard.js delete mode 100644 web/classic/src/components/table/mj-logs/MjLogsActions.jsx delete mode 100644 web/classic/src/components/table/mj-logs/MjLogsColumnDefs.jsx delete mode 100644 web/classic/src/components/table/mj-logs/MjLogsFilters.jsx delete mode 100644 web/classic/src/components/table/mj-logs/MjLogsTable.jsx delete mode 100644 web/classic/src/components/table/mj-logs/index.jsx delete mode 100644 web/classic/src/components/table/mj-logs/modals/ColumnSelectorModal.jsx delete mode 100644 web/classic/src/components/table/mj-logs/modals/ContentModal.jsx delete mode 100644 web/classic/src/components/table/model-deployments/DeploymentsActions.jsx delete mode 100644 web/classic/src/components/table/model-deployments/DeploymentsColumnDefs.jsx delete mode 100644 web/classic/src/components/table/model-deployments/DeploymentsFilters.jsx delete mode 100644 web/classic/src/components/table/model-deployments/DeploymentsTable.jsx delete mode 100644 web/classic/src/components/table/model-deployments/index.jsx delete mode 100644 web/classic/src/components/table/model-deployments/modals/ColumnSelectorModal.jsx delete mode 100644 web/classic/src/components/table/model-deployments/modals/ConfirmationDialog.jsx delete mode 100644 web/classic/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx delete mode 100644 web/classic/src/components/table/model-deployments/modals/EditDeploymentModal.jsx delete mode 100644 web/classic/src/components/table/model-deployments/modals/ExtendDurationModal.jsx delete mode 100644 web/classic/src/components/table/model-deployments/modals/UpdateConfigModal.jsx delete mode 100644 web/classic/src/components/table/model-deployments/modals/ViewDetailsModal.jsx delete mode 100644 web/classic/src/components/table/model-deployments/modals/ViewLogsModal.jsx delete mode 100644 web/classic/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx delete mode 100644 web/classic/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx delete mode 100644 web/classic/src/components/table/model-pricing/filter/PricingGroups.jsx delete mode 100644 web/classic/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx delete mode 100644 web/classic/src/components/table/model-pricing/filter/PricingTags.jsx delete mode 100644 web/classic/src/components/table/model-pricing/filter/PricingVendors.jsx delete mode 100644 web/classic/src/components/table/model-pricing/layout/PricingPage.jsx delete mode 100644 web/classic/src/components/table/model-pricing/layout/PricingSidebar.jsx delete mode 100644 web/classic/src/components/table/model-pricing/layout/content/PricingContent.jsx delete mode 100644 web/classic/src/components/table/model-pricing/layout/content/PricingView.jsx delete mode 100644 web/classic/src/components/table/model-pricing/layout/header/PricingTopSection.jsx delete mode 100644 web/classic/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx delete mode 100644 web/classic/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx delete mode 100644 web/classic/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx delete mode 100644 web/classic/src/components/table/model-pricing/layout/header/SearchActions.jsx delete mode 100644 web/classic/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx delete mode 100644 web/classic/src/components/table/model-pricing/modal/PricingFilterModal.jsx delete mode 100644 web/classic/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx delete mode 100644 web/classic/src/components/table/model-pricing/modal/components/FilterModalContent.jsx delete mode 100644 web/classic/src/components/table/model-pricing/modal/components/FilterModalFooter.jsx delete mode 100644 web/classic/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx delete mode 100644 web/classic/src/components/table/model-pricing/modal/components/ModelEndpoints.jsx delete mode 100644 web/classic/src/components/table/model-pricing/modal/components/ModelHeader.jsx delete mode 100644 web/classic/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx delete mode 100644 web/classic/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx delete mode 100644 web/classic/src/components/table/model-pricing/view/card/PricingCardView.jsx delete mode 100644 web/classic/src/components/table/model-pricing/view/table/PricingTable.jsx delete mode 100644 web/classic/src/components/table/model-pricing/view/table/PricingTableColumns.jsx delete mode 100644 web/classic/src/components/table/models/ModelsActions.jsx delete mode 100644 web/classic/src/components/table/models/ModelsColumnDefs.jsx delete mode 100644 web/classic/src/components/table/models/ModelsDescription.jsx delete mode 100644 web/classic/src/components/table/models/ModelsFilters.jsx delete mode 100644 web/classic/src/components/table/models/ModelsTable.jsx delete mode 100644 web/classic/src/components/table/models/ModelsTabs.jsx delete mode 100644 web/classic/src/components/table/models/components/SelectionNotification.jsx delete mode 100644 web/classic/src/components/table/models/index.jsx delete mode 100644 web/classic/src/components/table/models/modals/EditModelModal.jsx delete mode 100644 web/classic/src/components/table/models/modals/EditPrefillGroupModal.jsx delete mode 100644 web/classic/src/components/table/models/modals/EditVendorModal.jsx delete mode 100644 web/classic/src/components/table/models/modals/MissingModelsModal.jsx delete mode 100644 web/classic/src/components/table/models/modals/PrefillGroupManagement.jsx delete mode 100644 web/classic/src/components/table/models/modals/SyncWizardModal.jsx delete mode 100644 web/classic/src/components/table/models/modals/UpstreamConflictModal.jsx delete mode 100644 web/classic/src/components/table/redemptions/RedemptionsActions.jsx delete mode 100644 web/classic/src/components/table/redemptions/RedemptionsColumnDefs.jsx delete mode 100644 web/classic/src/components/table/redemptions/RedemptionsDescription.jsx delete mode 100644 web/classic/src/components/table/redemptions/RedemptionsFilters.jsx delete mode 100644 web/classic/src/components/table/redemptions/RedemptionsTable.jsx delete mode 100644 web/classic/src/components/table/redemptions/index.jsx delete mode 100644 web/classic/src/components/table/redemptions/modals/DeleteRedemptionModal.jsx delete mode 100644 web/classic/src/components/table/redemptions/modals/EditRedemptionModal.jsx delete mode 100644 web/classic/src/components/table/subscriptions/SubscriptionsActions.jsx delete mode 100644 web/classic/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx delete mode 100644 web/classic/src/components/table/subscriptions/SubscriptionsDescription.jsx delete mode 100644 web/classic/src/components/table/subscriptions/SubscriptionsTable.jsx delete mode 100644 web/classic/src/components/table/subscriptions/index.jsx delete mode 100644 web/classic/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx delete mode 100644 web/classic/src/components/table/task-logs/TaskLogsActions.jsx delete mode 100644 web/classic/src/components/table/task-logs/TaskLogsColumnDefs.jsx delete mode 100644 web/classic/src/components/table/task-logs/TaskLogsFilters.jsx delete mode 100644 web/classic/src/components/table/task-logs/TaskLogsTable.jsx delete mode 100644 web/classic/src/components/table/task-logs/index.jsx delete mode 100644 web/classic/src/components/table/task-logs/modals/AudioPreviewModal.jsx delete mode 100644 web/classic/src/components/table/task-logs/modals/ColumnSelectorModal.jsx delete mode 100644 web/classic/src/components/table/task-logs/modals/ContentModal.jsx delete mode 100644 web/classic/src/components/table/tokens/TokensActions.jsx delete mode 100644 web/classic/src/components/table/tokens/TokensColumnDefs.jsx delete mode 100644 web/classic/src/components/table/tokens/TokensDescription.jsx delete mode 100644 web/classic/src/components/table/tokens/TokensFilters.jsx delete mode 100644 web/classic/src/components/table/tokens/TokensTable.jsx delete mode 100644 web/classic/src/components/table/tokens/index.jsx delete mode 100644 web/classic/src/components/table/tokens/modals/CCSwitchModal.jsx delete mode 100644 web/classic/src/components/table/tokens/modals/CopyTokensModal.jsx delete mode 100644 web/classic/src/components/table/tokens/modals/DeleteTokensModal.jsx delete mode 100644 web/classic/src/components/table/tokens/modals/EditTokenModal.jsx delete mode 100644 web/classic/src/components/table/usage-logs/UsageLogsActions.jsx delete mode 100644 web/classic/src/components/table/usage-logs/UsageLogsColumnDefs.jsx delete mode 100644 web/classic/src/components/table/usage-logs/UsageLogsFilters.jsx delete mode 100644 web/classic/src/components/table/usage-logs/UsageLogsTable.jsx delete mode 100644 web/classic/src/components/table/usage-logs/components/ParamOverrideEntry.jsx delete mode 100644 web/classic/src/components/table/usage-logs/index.jsx delete mode 100644 web/classic/src/components/table/usage-logs/modals/ChannelAffinityUsageCacheModal.jsx delete mode 100644 web/classic/src/components/table/usage-logs/modals/ColumnSelectorModal.jsx delete mode 100644 web/classic/src/components/table/usage-logs/modals/ParamOverrideModal.jsx delete mode 100644 web/classic/src/components/table/usage-logs/modals/UserInfoModal.jsx delete mode 100644 web/classic/src/components/table/users/UsersActions.jsx delete mode 100644 web/classic/src/components/table/users/UsersColumnDefs.jsx delete mode 100644 web/classic/src/components/table/users/UsersDescription.jsx delete mode 100644 web/classic/src/components/table/users/UsersFilters.jsx delete mode 100644 web/classic/src/components/table/users/UsersTable.jsx delete mode 100644 web/classic/src/components/table/users/index.jsx delete mode 100644 web/classic/src/components/table/users/modals/AddUserModal.jsx delete mode 100644 web/classic/src/components/table/users/modals/DeleteUserModal.jsx delete mode 100644 web/classic/src/components/table/users/modals/DemoteUserModal.jsx delete mode 100644 web/classic/src/components/table/users/modals/EditUserModal.jsx delete mode 100644 web/classic/src/components/table/users/modals/EnableDisableUserModal.jsx delete mode 100644 web/classic/src/components/table/users/modals/PromoteUserModal.jsx delete mode 100644 web/classic/src/components/table/users/modals/ResetPasskeyModal.jsx delete mode 100644 web/classic/src/components/table/users/modals/ResetTwoFAModal.jsx delete mode 100644 web/classic/src/components/table/users/modals/UserBindingManagementModal.jsx delete mode 100644 web/classic/src/components/table/users/modals/UserSubscriptionsModal.jsx delete mode 100644 web/classic/src/components/topup/InvitationCard.jsx delete mode 100644 web/classic/src/components/topup/RechargeCard.jsx delete mode 100644 web/classic/src/components/topup/SubscriptionPlansCard.jsx delete mode 100644 web/classic/src/components/topup/index.jsx delete mode 100644 web/classic/src/components/topup/modals/PaymentConfirmModal.jsx delete mode 100644 web/classic/src/components/topup/modals/SubscriptionPurchaseModal.jsx delete mode 100644 web/classic/src/components/topup/modals/TopupHistoryModal.jsx delete mode 100644 web/classic/src/components/topup/modals/TransferModal.jsx delete mode 100644 web/classic/src/constants/billing.constants.js delete mode 100644 web/classic/src/constants/channel-affinity-template.constants.js delete mode 100644 web/classic/src/constants/channel.constants.js delete mode 100644 web/classic/src/constants/common.constant.js delete mode 100644 web/classic/src/constants/console.constants.js delete mode 100644 web/classic/src/constants/dashboard.constants.js delete mode 100644 web/classic/src/constants/index.js delete mode 100644 web/classic/src/constants/playground.constants.js delete mode 100644 web/classic/src/constants/redemption.constants.js delete mode 100644 web/classic/src/constants/toast.constants.js delete mode 100644 web/classic/src/constants/user.constants.js delete mode 100644 web/classic/src/context/Status/index.jsx delete mode 100644 web/classic/src/context/Status/reducer.js delete mode 100644 web/classic/src/context/Theme/index.jsx delete mode 100644 web/classic/src/context/User/index.jsx delete mode 100644 web/classic/src/context/User/reducer.js delete mode 100644 web/classic/src/contexts/PlaygroundContext.jsx delete mode 100644 web/classic/src/helpers/api.js delete mode 100644 web/classic/src/helpers/auth.jsx delete mode 100644 web/classic/src/helpers/base64.js delete mode 100644 web/classic/src/helpers/boolean.js delete mode 100644 web/classic/src/helpers/dashboard.jsx delete mode 100644 web/classic/src/helpers/data.js delete mode 100644 web/classic/src/helpers/frontendTheme.js delete mode 100644 web/classic/src/helpers/history.js delete mode 100644 web/classic/src/helpers/index.js delete mode 100644 web/classic/src/helpers/log.js delete mode 100644 web/classic/src/helpers/passkey.js delete mode 100644 web/classic/src/helpers/quota.js delete mode 100644 web/classic/src/helpers/render.jsx delete mode 100644 web/classic/src/helpers/secureApiCall.js delete mode 100644 web/classic/src/helpers/statusCodeRules.js delete mode 100644 web/classic/src/helpers/subscriptionFormat.js delete mode 100644 web/classic/src/helpers/token.js delete mode 100644 web/classic/src/helpers/utils.jsx delete mode 100644 web/classic/src/hooks/channels/upstreamUpdateUtils.js delete mode 100644 web/classic/src/hooks/channels/useChannelUpstreamUpdates.jsx delete mode 100644 web/classic/src/hooks/channels/useChannelsData.jsx delete mode 100644 web/classic/src/hooks/chat/useTokenKeys.js delete mode 100644 web/classic/src/hooks/common/useContainerWidth.js delete mode 100644 web/classic/src/hooks/common/useHeaderBar.js delete mode 100644 web/classic/src/hooks/common/useIsMobile.js delete mode 100644 web/classic/src/hooks/common/useMinimumLoadingTime.js delete mode 100644 web/classic/src/hooks/common/useNavigation.js delete mode 100644 web/classic/src/hooks/common/useNotifications.js delete mode 100644 web/classic/src/hooks/common/useSecureVerification.jsx delete mode 100644 web/classic/src/hooks/common/useSidebar.js delete mode 100644 web/classic/src/hooks/common/useSidebarCollapsed.js delete mode 100644 web/classic/src/hooks/common/useTableCompactMode.js delete mode 100644 web/classic/src/hooks/common/useUserPermissions.js delete mode 100644 web/classic/src/hooks/dashboard/useDashboardCharts.jsx delete mode 100644 web/classic/src/hooks/dashboard/useDashboardData.js delete mode 100644 web/classic/src/hooks/dashboard/useDashboardStats.jsx delete mode 100644 web/classic/src/hooks/mj-logs/useMjLogsData.js delete mode 100644 web/classic/src/hooks/model-deployments/useDeploymentResources.js delete mode 100644 web/classic/src/hooks/model-deployments/useDeploymentsData.jsx delete mode 100644 web/classic/src/hooks/model-deployments/useEnhancedDeploymentActions.jsx delete mode 100644 web/classic/src/hooks/model-deployments/useModelDeploymentSettings.js delete mode 100644 web/classic/src/hooks/model-pricing/useModelPricingData.jsx delete mode 100644 web/classic/src/hooks/model-pricing/usePricingFilterCounts.js delete mode 100644 web/classic/src/hooks/models/useModelsData.jsx delete mode 100644 web/classic/src/hooks/playground/useApiRequest.jsx delete mode 100644 web/classic/src/hooks/playground/useDataLoader.js delete mode 100644 web/classic/src/hooks/playground/useMessageActions.jsx delete mode 100644 web/classic/src/hooks/playground/useMessageEdit.jsx delete mode 100644 web/classic/src/hooks/playground/usePlaygroundState.js delete mode 100644 web/classic/src/hooks/playground/useSyncMessageAndCustomBody.js delete mode 100644 web/classic/src/hooks/redemptions/useRedemptionsData.jsx delete mode 100644 web/classic/src/hooks/subscriptions/useSubscriptionsData.jsx delete mode 100644 web/classic/src/hooks/task-logs/useTaskLogsData.js delete mode 100644 web/classic/src/hooks/tokens/useTokensData.jsx delete mode 100644 web/classic/src/hooks/usage-logs/useUsageLogsData.jsx delete mode 100644 web/classic/src/hooks/users/useUsersData.jsx delete mode 100644 web/classic/src/i18n/i18n.js delete mode 100644 web/classic/src/i18n/language.js delete mode 100644 web/classic/src/i18n/locales/en.json delete mode 100644 web/classic/src/i18n/locales/fr.json delete mode 100644 web/classic/src/i18n/locales/ja.json delete mode 100644 web/classic/src/i18n/locales/ru.json delete mode 100644 web/classic/src/i18n/locales/vi.json delete mode 100644 web/classic/src/i18n/locales/zh-CN.json delete mode 100644 web/classic/src/i18n/locales/zh-TW.json delete mode 100644 web/classic/src/i18n/locales/zh.json delete mode 100644 web/classic/src/index.css delete mode 100644 web/classic/src/index.jsx delete mode 100644 web/classic/src/pages/About/index.jsx delete mode 100644 web/classic/src/pages/Channel/index.jsx delete mode 100644 web/classic/src/pages/Chat/index.jsx delete mode 100644 web/classic/src/pages/Chat2Link/index.jsx delete mode 100644 web/classic/src/pages/Dashboard/index.jsx delete mode 100644 web/classic/src/pages/Forbidden/index.jsx delete mode 100644 web/classic/src/pages/Home/index.jsx delete mode 100644 web/classic/src/pages/Log/index.jsx delete mode 100644 web/classic/src/pages/Midjourney/index.jsx delete mode 100644 web/classic/src/pages/Model/index.jsx delete mode 100644 web/classic/src/pages/ModelDeployment/index.jsx delete mode 100644 web/classic/src/pages/NotFound/index.jsx delete mode 100644 web/classic/src/pages/Playground/index.jsx delete mode 100644 web/classic/src/pages/Pricing/index.jsx delete mode 100644 web/classic/src/pages/PrivacyPolicy/index.jsx delete mode 100644 web/classic/src/pages/Redemption/index.jsx delete mode 100644 web/classic/src/pages/Setting/Chat/SettingsChats.jsx delete mode 100644 web/classic/src/pages/Setting/Dashboard/SettingsAPIInfo.jsx delete mode 100644 web/classic/src/pages/Setting/Dashboard/SettingsAnnouncements.jsx delete mode 100644 web/classic/src/pages/Setting/Dashboard/SettingsDataDashboard.jsx delete mode 100644 web/classic/src/pages/Setting/Dashboard/SettingsFAQ.jsx delete mode 100644 web/classic/src/pages/Setting/Dashboard/SettingsUptimeKuma.jsx delete mode 100644 web/classic/src/pages/Setting/Drawing/SettingsDrawing.jsx delete mode 100644 web/classic/src/pages/Setting/Model/SettingClaudeModel.jsx delete mode 100644 web/classic/src/pages/Setting/Model/SettingGeminiModel.jsx delete mode 100644 web/classic/src/pages/Setting/Model/SettingGlobalModel.jsx delete mode 100644 web/classic/src/pages/Setting/Model/SettingGrokModel.jsx delete mode 100644 web/classic/src/pages/Setting/Model/SettingModelDeployment.jsx delete mode 100644 web/classic/src/pages/Setting/Operation/SettingsChannelAffinity.jsx delete mode 100644 web/classic/src/pages/Setting/Operation/SettingsCheckin.jsx delete mode 100644 web/classic/src/pages/Setting/Operation/SettingsCreditLimit.jsx delete mode 100644 web/classic/src/pages/Setting/Operation/SettingsGeneral.jsx delete mode 100644 web/classic/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx delete mode 100644 web/classic/src/pages/Setting/Operation/SettingsLog.jsx delete mode 100644 web/classic/src/pages/Setting/Operation/SettingsMonitoring.jsx delete mode 100644 web/classic/src/pages/Setting/Operation/SettingsSensitiveWords.jsx delete mode 100644 web/classic/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx delete mode 100644 web/classic/src/pages/Setting/Payment/SettingsGeneralPayment.jsx delete mode 100644 web/classic/src/pages/Setting/Payment/SettingsPaymentGateway.jsx delete mode 100644 web/classic/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx delete mode 100644 web/classic/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx delete mode 100644 web/classic/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx delete mode 100644 web/classic/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx delete mode 100644 web/classic/src/pages/Setting/Performance/SettingsPerformance.jsx delete mode 100644 web/classic/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx delete mode 100644 web/classic/src/pages/Setting/RateLimit/SettingsRequestRateLimit.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/GroupRatioSettings.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/ModelPricingCombined.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/ModelRatioSettings.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/ToolPriceSettings.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/UpstreamRatioSync.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/components/AutoGroupList.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/components/GroupGroupRatioRules.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/components/GroupSpecialUsableRules.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/components/GroupTable.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx delete mode 100644 web/classic/src/pages/Setting/Ratio/components/requestRuleExpr.js delete mode 100644 web/classic/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js delete mode 100644 web/classic/src/pages/Setting/index.jsx delete mode 100644 web/classic/src/pages/Setup/index.jsx delete mode 100644 web/classic/src/pages/Subscription/index.jsx delete mode 100644 web/classic/src/pages/Task/index.jsx delete mode 100644 web/classic/src/pages/Token/index.jsx delete mode 100644 web/classic/src/pages/TopUp/index.js delete mode 100644 web/classic/src/pages/User/index.jsx delete mode 100644 web/classic/src/pages/UserAgreement/index.jsx delete mode 100644 web/classic/src/services/secureVerification.js delete mode 100644 web/classic/tailwind.config.js delete mode 100644 web/classic/vercel.json rename web/{default => }/components.json (100%) rename web/{default => }/cz.yaml (100%) delete mode 100644 web/default/package.json delete mode 100644 web/default/public/favicon.ico delete mode 100644 web/default/public/logo.png delete mode 100644 web/default/public/pay-apple.png delete mode 100644 web/default/public/pay-card.png delete mode 100644 web/default/public/pay-google.png delete mode 100644 web/default/public/waffo-logo-dark.svg delete mode 100644 web/default/public/waffo-logo-light.svg delete mode 100644 web/default/src/components/truncated-text.tsx delete mode 100644 web/default/src/components/ui/dropdown-menu-events.ts delete mode 100644 web/default/src/features/profile/components/dialogs/wechat-bind-dialog.tsx rename web/{default => }/index.html (100%) rename web/{default => }/knip.config.ts (100%) rename web/{default => }/netlify.toml (100%) rename web/{classic => }/public/favicon.ico (100%) rename web/{classic => }/public/logo.png (100%) rename web/{classic => }/public/pay-apple.png (100%) rename web/{classic => }/public/pay-card.png (100%) rename web/{classic => }/public/pay-google.png (100%) rename web/{classic => }/public/waffo-logo-dark.svg (100%) rename web/{classic => }/public/waffo-logo-light.svg (100%) rename web/{default => }/rsbuild.config.ts (100%) rename web/{default => }/scripts/add-copyright.mjs (96%) rename web/{default => }/scripts/format-with-protected-headers.mjs (100%) rename web/{default => }/scripts/sync-i18n.mjs (100%) rename web/{default => }/src/assets/brand-icons/icon-discord.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-docker.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-facebook.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-figma.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-github.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-gitlab.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-gmail.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-linuxdo.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-medium.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-notion.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-skype.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-slack.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-stripe.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-telegram.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-trello.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-wechat.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-whatsapp.tsx (100%) rename web/{default => }/src/assets/brand-icons/icon-zoom.tsx (100%) rename web/{default => }/src/assets/brand-icons/index.ts (100%) rename web/{default => }/src/assets/clerk-full-logo.tsx (100%) rename web/{default => }/src/assets/clerk-logo.tsx (100%) rename web/{default => }/src/assets/custom/icon-dir.tsx (100%) rename web/{default => }/src/assets/custom/icon-layout-compact.tsx (100%) rename web/{default => }/src/assets/custom/icon-layout-default.tsx (100%) rename web/{default => }/src/assets/custom/icon-layout-full.tsx (100%) rename web/{default => }/src/assets/custom/icon-sidebar-floating.tsx (100%) rename web/{default => }/src/assets/custom/icon-sidebar-inset.tsx (100%) rename web/{default => }/src/assets/custom/icon-sidebar-sidebar.tsx (100%) rename web/{default => }/src/assets/custom/icon-theme-dark.tsx (100%) rename web/{default => }/src/assets/custom/icon-theme-light.tsx (100%) rename web/{default => }/src/assets/custom/icon-theme-system.tsx (100%) rename web/{default => }/src/assets/logo.tsx (100%) rename web/{default => }/src/components/ai-elements/actions.tsx (100%) rename web/{default => }/src/components/ai-elements/artifact.tsx (100%) rename web/{default => }/src/components/ai-elements/branch.tsx (100%) rename web/{default => }/src/components/ai-elements/canvas.tsx (100%) rename web/{default => }/src/components/ai-elements/chain-of-thought.tsx (100%) rename web/{default => }/src/components/ai-elements/code-block.tsx (100%) rename web/{default => }/src/components/ai-elements/confirmation.tsx (100%) rename web/{default => }/src/components/ai-elements/connection.tsx (100%) rename web/{default => }/src/components/ai-elements/context.tsx (100%) rename web/{default => }/src/components/ai-elements/controls.tsx (100%) rename web/{default => }/src/components/ai-elements/conversation.tsx (100%) rename web/{default => }/src/components/ai-elements/edge.tsx (100%) rename web/{default => }/src/components/ai-elements/image.tsx (100%) rename web/{default => }/src/components/ai-elements/inline-citation.tsx (100%) rename web/{default => }/src/components/ai-elements/loader.tsx (100%) rename web/{default => }/src/components/ai-elements/message.tsx (100%) rename web/{default => }/src/components/ai-elements/node.tsx (100%) rename web/{default => }/src/components/ai-elements/open-in-chat.tsx (100%) rename web/{default => }/src/components/ai-elements/panel.tsx (100%) rename web/{default => }/src/components/ai-elements/plan.tsx (100%) rename web/{default => }/src/components/ai-elements/prompt-input.tsx (100%) rename web/{default => }/src/components/ai-elements/queue.tsx (100%) rename web/{default => }/src/components/ai-elements/reasoning.tsx (100%) rename web/{default => }/src/components/ai-elements/response-content.ts (100%) rename web/{default => }/src/components/ai-elements/response-node-guards.ts (100%) rename web/{default => }/src/components/ai-elements/response-renderer-alert.tsx (100%) rename web/{default => }/src/components/ai-elements/response-renderer-blocks.tsx (100%) rename web/{default => }/src/components/ai-elements/response-renderer-details.tsx (100%) rename web/{default => }/src/components/ai-elements/response-renderer-footnotes.tsx (100%) rename web/{default => }/src/components/ai-elements/response-renderer-image.tsx (100%) rename web/{default => }/src/components/ai-elements/response-renderer-inline.tsx (100%) rename web/{default => }/src/components/ai-elements/response-renderer-table.tsx (100%) rename web/{default => }/src/components/ai-elements/response-renderer.tsx (100%) rename web/{default => }/src/components/ai-elements/response-types.ts (100%) rename web/{default => }/src/components/ai-elements/response.tsx (100%) rename web/{default => }/src/components/ai-elements/shimmer.tsx (100%) rename web/{default => }/src/components/ai-elements/sources.tsx (100%) rename web/{default => }/src/components/ai-elements/suggestion.tsx (100%) rename web/{default => }/src/components/ai-elements/task.tsx (100%) rename web/{default => }/src/components/ai-elements/tool.tsx (100%) rename web/{default => }/src/components/ai-elements/toolbar.tsx (100%) rename web/{default => }/src/components/ai-elements/web-preview.tsx (100%) rename web/{default => }/src/components/animate-in-view.tsx (100%) rename web/{default => }/src/components/auto-skeleton.tsx (100%) rename web/{default => }/src/components/coming-soon.tsx (100%) rename web/{default => }/src/components/command-menu.tsx (100%) rename web/{default => }/src/components/config-drawer.tsx (100%) rename web/{default => }/src/components/confirm-dialog.tsx (100%) rename web/{default => }/src/components/copy-button.tsx (100%) rename web/{default => }/src/components/data-table/README.md (100%) rename web/{default => }/src/components/data-table/core/badge-cell.tsx (100%) rename web/{default => }/src/components/data-table/core/badge-list-cell.tsx (100%) rename web/{default => }/src/components/data-table/core/column-header.tsx (100%) rename web/{default => }/src/components/data-table/core/column-pinning.ts (100%) rename web/{default => }/src/components/data-table/core/content-sized-columns.ts (100%) rename web/{default => }/src/components/data-table/core/data-table-colgroup.tsx (100%) rename web/{default => }/src/components/data-table/core/data-table-header.tsx (100%) rename web/{default => }/src/components/data-table/core/data-table-row.tsx (100%) rename web/{default => }/src/components/data-table/core/data-table-view.tsx (100%) rename web/{default => }/src/components/data-table/core/pagination.tsx (100%) rename web/{default => }/src/components/data-table/core/row-action-menu.tsx (100%) rename web/{default => }/src/components/data-table/core/table-empty.tsx (100%) rename web/{default => }/src/components/data-table/core/table-sizing.ts (100%) rename web/{default => }/src/components/data-table/core/table-skeleton.tsx (100%) rename web/{default => }/src/components/data-table/core/truncated-cell.tsx (100%) rename web/{default => }/src/components/data-table/core/types.ts (100%) rename web/{default => }/src/components/data-table/hooks/use-data-table-view-mode.ts (100%) rename web/{default => }/src/components/data-table/hooks/use-data-table.ts (100%) rename web/{default => }/src/components/data-table/hooks/use-debounced-column-filter.ts (100%) rename web/{default => }/src/components/data-table/index.ts (100%) rename web/{default => }/src/components/data-table/layout/card-cell-utils.ts (100%) rename web/{default => }/src/components/data-table/layout/card-grid.tsx (89%) rename web/{default => }/src/components/data-table/layout/card-row-content.tsx (100%) rename web/{default => }/src/components/data-table/layout/data-table-page.tsx (95%) rename web/{default => }/src/components/data-table/layout/mobile-card-list.tsx (86%) rename web/{default => }/src/components/data-table/static/static-data-table-classnames.ts (100%) rename web/{default => }/src/components/data-table/static/static-data-table.tsx (100%) rename web/{default => }/src/components/data-table/static/static-row-actions.tsx (100%) rename web/{default => }/src/components/data-table/toolbar/bulk-actions.tsx (100%) rename web/{default => }/src/components/data-table/toolbar/faceted-filter.tsx (100%) rename web/{default => }/src/components/data-table/toolbar/toolbar.tsx (100%) rename web/{default => }/src/components/data-table/toolbar/view-mode-toggle.tsx (100%) rename web/{default => }/src/components/data-table/toolbar/view-options.tsx (100%) rename web/{default => }/src/components/date-picker.tsx (100%) rename web/{default => }/src/components/datetime-picker.tsx (100%) rename web/{default => }/src/components/dialog.tsx (100%) rename web/{default => }/src/components/drawer-layout.ts (100%) rename web/{default => }/src/components/empty-state.tsx (100%) rename web/{default => }/src/components/error-state.tsx (100%) rename web/{default => }/src/components/group-badge.tsx (100%) rename web/{default => }/src/components/html-content.tsx (96%) rename web/{default => }/src/components/json-code-editor.tsx (100%) rename web/{default => }/src/components/json-editor.tsx (100%) rename web/{default => }/src/components/language-switcher.tsx (100%) rename web/{default => }/src/components/layout/components/app-header.tsx (100%) rename web/{default => }/src/components/layout/components/app-sidebar.tsx (100%) rename web/{default => }/src/components/layout/components/authenticated-layout.tsx (100%) rename web/{default => }/src/components/layout/components/chat-presets-item.tsx (100%) rename web/{default => }/src/components/layout/components/footer.tsx (100%) rename web/{default => }/src/components/layout/components/glow.tsx (100%) rename web/{default => }/src/components/layout/components/header-logo.tsx (100%) rename web/{default => }/src/components/layout/components/header.tsx (100%) rename web/{default => }/src/components/layout/components/logo.tsx (100%) rename web/{default => }/src/components/layout/components/main.tsx (100%) rename web/{default => }/src/components/layout/components/mobile-drawer.tsx (100%) rename web/{default => }/src/components/layout/components/mockup.tsx (100%) rename web/{default => }/src/components/layout/components/nav-group.tsx (100%) rename web/{default => }/src/components/layout/components/nav-link-item.tsx (100%) rename web/{default => }/src/components/layout/components/navbar.tsx (100%) rename web/{default => }/src/components/layout/components/page-footer.tsx (100%) rename web/{default => }/src/components/layout/components/public-header.tsx (100%) rename web/{default => }/src/components/layout/components/public-layout.tsx (100%) rename web/{default => }/src/components/layout/components/public-navigation.tsx (100%) rename web/{default => }/src/components/layout/components/section-page-layout.tsx (100%) rename web/{default => }/src/components/layout/components/section.tsx (100%) rename web/{default => }/src/components/layout/components/sidebar-view-header.tsx (100%) rename web/{default => }/src/components/layout/components/system-brand.tsx (100%) rename web/{default => }/src/components/layout/components/top-nav.tsx (100%) rename web/{default => }/src/components/layout/config/system-settings.config.ts (100%) rename web/{default => }/src/components/layout/config/top-nav.config.ts (100%) rename web/{default => }/src/components/layout/constants.ts (100%) rename web/{default => }/src/components/layout/index.ts (100%) rename web/{default => }/src/components/layout/lib/sidebar-view-registry.ts (100%) rename web/{default => }/src/components/layout/lib/url-utils.ts (100%) rename web/{default => }/src/components/layout/types.ts (100%) rename web/{default => }/src/components/learn-more.tsx (100%) rename web/{default => }/src/components/loading-state.tsx (100%) rename web/{default => }/src/components/long-text.tsx (100%) rename web/{default => }/src/components/masked-value-display.tsx (100%) rename web/{default => }/src/components/model-group-selector-layout.ts (99%) rename web/{default => }/src/components/model-group-selector.tsx (97%) rename web/{default => }/src/components/multi-select.tsx (99%) rename web/{default => }/src/components/navigation-progress.tsx (100%) rename web/{default => }/src/components/notification-popover.tsx (100%) rename web/{default => }/src/components/page-transition.tsx (100%) rename web/{default => }/src/components/password-input.tsx (100%) rename web/{default => }/src/components/profile-dropdown.tsx (100%) rename web/{default => }/src/components/provider-badge.tsx (100%) rename web/{default => }/src/components/react-icon-by-name.tsx (100%) rename web/{default => }/src/components/rich-content.tsx (94%) rename web/{default => }/src/components/risk-acknowledgement-dialog.tsx (100%) rename web/{default => }/src/components/search.tsx (100%) rename web/{default => }/src/components/sign-out-dialog.tsx (100%) rename web/{default => }/src/components/skip-to-main.tsx (100%) rename web/{default => }/src/components/status-badge.tsx (90%) rename web/{default => }/src/components/table-id.tsx (100%) rename web/{default => }/src/components/tag-input.tsx (100%) rename web/{default => }/src/components/theme-quick-switcher.tsx (100%) rename web/{default => }/src/components/theme-switch.tsx (100%) rename web/{default/src/routes/console/topup.tsx => src/components/truncated-text.tsx} (60%) rename web/{default => }/src/components/turnstile.tsx (100%) rename web/{default => }/src/components/ui/accordion.tsx (100%) rename web/{default => }/src/components/ui/alert-dialog.tsx (100%) rename web/{default => }/src/components/ui/alert.tsx (100%) rename web/{default => }/src/components/ui/aspect-ratio.tsx (100%) rename web/{default => }/src/components/ui/avatar.tsx (100%) rename web/{default => }/src/components/ui/badge.tsx (100%) rename web/{default => }/src/components/ui/breadcrumb.tsx (100%) rename web/{default => }/src/components/ui/button-group.tsx (100%) rename web/{default => }/src/components/ui/button.tsx (100%) rename web/{default => }/src/components/ui/calendar.tsx (100%) rename web/{default => }/src/components/ui/card.tsx (100%) rename web/{default => }/src/components/ui/carousel.tsx (100%) rename web/{default => }/src/components/ui/chart.tsx (100%) rename web/{default => }/src/components/ui/checkbox.tsx (100%) rename web/{default => }/src/components/ui/collapsible.tsx (100%) rename web/{default => }/src/components/ui/combobox-input.tsx (100%) rename web/{default => }/src/components/ui/combobox.tsx (100%) rename web/{default => }/src/components/ui/command.tsx (100%) rename web/{default => }/src/components/ui/context-menu.tsx (100%) rename web/{default => }/src/components/ui/dialog.tsx (100%) rename web/{default => }/src/components/ui/direction.tsx (100%) rename web/{default => }/src/components/ui/drawer.tsx (100%) rename web/{default/src/routes/console/log.tsx => src/components/ui/dropdown-menu-events.ts} (56%) rename web/{default => }/src/components/ui/dropdown-menu.test.tsx (64%) rename web/{default => }/src/components/ui/dropdown-menu.tsx (100%) rename web/{default => }/src/components/ui/empty.tsx (100%) rename web/{default => }/src/components/ui/field.tsx (100%) rename web/{default => }/src/components/ui/form.tsx (100%) rename web/{default => }/src/components/ui/hover-card.tsx (100%) rename web/{default => }/src/components/ui/icon-badge.tsx (100%) rename web/{default => }/src/components/ui/input-group.tsx (100%) rename web/{default => }/src/components/ui/input-otp.tsx (100%) rename web/{default => }/src/components/ui/input.tsx (100%) rename web/{default => }/src/components/ui/item.tsx (100%) rename web/{default => }/src/components/ui/kbd.tsx (100%) rename web/{default => }/src/components/ui/label.tsx (100%) rename web/{default => }/src/components/ui/markdown.tsx (100%) rename web/{default => }/src/components/ui/menubar.tsx (100%) rename web/{default => }/src/components/ui/native-select.tsx (100%) rename web/{default => }/src/components/ui/navigation-menu.tsx (100%) rename web/{default => }/src/components/ui/pagination.tsx (100%) rename web/{default => }/src/components/ui/popover.tsx (100%) rename web/{default => }/src/components/ui/progress.tsx (100%) rename web/{default => }/src/components/ui/radio-group.tsx (100%) rename web/{default => }/src/components/ui/resizable.tsx (100%) rename web/{default => }/src/components/ui/scroll-area.tsx (100%) rename web/{default => }/src/components/ui/select.tsx (100%) rename web/{default => }/src/components/ui/separator.tsx (100%) rename web/{default => }/src/components/ui/sheet.tsx (100%) rename web/{default => }/src/components/ui/sidebar.tsx (100%) rename web/{default => }/src/components/ui/skeleton.tsx (100%) rename web/{default => }/src/components/ui/slider.tsx (100%) rename web/{default => }/src/components/ui/sonner.tsx (100%) rename web/{default => }/src/components/ui/spinner.tsx (100%) rename web/{default => }/src/components/ui/switch.tsx (100%) rename web/{default => }/src/components/ui/table.tsx (100%) rename web/{default => }/src/components/ui/tabs.tsx (100%) rename web/{default => }/src/components/ui/textarea.tsx (100%) rename web/{default => }/src/components/ui/titled-card.tsx (100%) rename web/{default => }/src/components/ui/toggle-group.tsx (100%) rename web/{default => }/src/components/ui/toggle.tsx (100%) rename web/{default => }/src/components/ui/tooltip.tsx (100%) rename web/{default => }/src/config/fonts.ts (100%) rename web/{default => }/src/context/direction-provider.tsx (100%) rename web/{default => }/src/context/font-provider.tsx (100%) rename web/{default => }/src/context/layout-provider.tsx (100%) rename web/{default => }/src/context/search-provider.tsx (100%) rename web/{default => }/src/context/theme-customization-provider.tsx (100%) rename web/{default => }/src/context/theme-provider.tsx (100%) rename web/{default => }/src/env.d.ts (87%) rename web/{default => }/src/features/about/api.ts (100%) rename web/{default => }/src/features/about/index.tsx (100%) rename web/{default => }/src/features/about/types.ts (100%) rename web/{default => }/src/features/auth/api.test.ts (100%) rename web/{default => }/src/features/auth/api.ts (93%) rename web/{default => }/src/features/auth/auth-layout.tsx (100%) rename web/{default => }/src/features/auth/components/legal-consent.tsx (100%) rename web/{default => }/src/features/auth/components/oauth-callback-screen.tsx (100%) rename web/{default => }/src/features/auth/components/oauth-providers.tsx (69%) create mode 100644 web/src/features/auth/components/telegram-login-dialog.tsx rename web/{default => }/src/features/auth/components/terms-footer.tsx (100%) rename web/{default => }/src/features/auth/constants.ts (100%) rename web/{default => }/src/features/auth/forgot-password/components/forgot-password-form.tsx (100%) rename web/{default => }/src/features/auth/forgot-password/index.tsx (100%) rename web/{default => }/src/features/auth/hooks/use-auth-redirect.ts (100%) rename web/{default => }/src/features/auth/hooks/use-email-verification.ts (100%) rename web/{default => }/src/features/auth/hooks/use-oauth-login.ts (75%) rename web/{default => }/src/features/auth/hooks/use-turnstile.ts (100%) rename web/{default => }/src/features/auth/index.ts (99%) rename web/{default => }/src/features/auth/lib/auth-redirect.test.ts (100%) rename web/{default => }/src/features/auth/lib/auth-redirect.ts (100%) rename web/{default => }/src/features/auth/lib/oauth-bind-window.test.ts (100%) rename web/{default => }/src/features/auth/lib/oauth-bind-window.ts (100%) rename web/{default => }/src/features/auth/lib/oauth.ts (100%) rename web/{default => }/src/features/auth/lib/storage.ts (100%) create mode 100644 web/src/features/auth/lib/telegram-login.test.ts create mode 100644 web/src/features/auth/lib/telegram-login.ts rename web/{default => }/src/features/auth/lib/validation.ts (100%) rename web/{default => }/src/features/auth/otp/components/otp-form.tsx (100%) rename web/{default => }/src/features/auth/otp/index.tsx (100%) rename web/{default => }/src/features/auth/passkey/api.ts (100%) rename web/{default => }/src/features/auth/passkey/hooks/use-passkey-management.ts (100%) rename web/{default => }/src/features/auth/passkey/index.ts (100%) rename web/{default => }/src/features/auth/passkey/types.ts (100%) rename web/{default => }/src/features/auth/reset-password-confirm/index.tsx (100%) rename web/{default => }/src/features/auth/secure-verification/api.ts (100%) rename web/{default => }/src/features/auth/secure-verification/components/secure-verification-dialog.tsx (100%) rename web/{default => }/src/features/auth/secure-verification/hooks/use-secure-verification.ts (100%) rename web/{default => }/src/features/auth/secure-verification/index.ts (100%) rename web/{default => }/src/features/auth/secure-verification/types.ts (100%) rename web/{default => }/src/features/auth/sign-in/components/user-auth-form.tsx (99%) rename web/{default => }/src/features/auth/sign-in/index.tsx (100%) rename web/{default => }/src/features/auth/sign-up/components/sign-up-form.tsx (100%) rename web/{default => }/src/features/auth/sign-up/index.tsx (100%) rename web/{default => }/src/features/auth/types.ts (98%) rename web/{default => }/src/features/channels/api.ts (100%) rename web/{default => }/src/features/channels/components/channel-card.tsx (100%) rename web/{default => }/src/features/channels/components/channel-row-actions-context.ts (100%) rename web/{default => }/src/features/channels/components/channels-columns.tsx (100%) rename web/{default => }/src/features/channels/components/channels-dialogs.tsx (100%) rename web/{default => }/src/features/channels/components/channels-primary-buttons.tsx (100%) rename web/{default => }/src/features/channels/components/channels-provider.tsx (100%) rename web/{default => }/src/features/channels/components/channels-table.tsx (100%) rename web/{default => }/src/features/channels/components/data-table-bulk-actions.tsx (100%) rename web/{default => }/src/features/channels/components/data-table-row-actions.tsx (100%) rename web/{default => }/src/features/channels/components/data-table-tag-row-actions.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/balance-query-dialog.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/channel-test-dialog.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/codex-usage-dialog.tsx (98%) rename web/{default => }/src/features/channels/components/dialogs/copy-channel-dialog.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/edit-tag-dialog.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/fetch-models-dialog.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/missing-models-confirmation-dialog.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/multi-key-statistics-card.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/multi-key-table-row-actions.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/ollama-models-dialog.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/param-override-editor-dialog.tsx (99%) rename web/{default => }/src/features/channels/components/dialogs/status-code-risk-dialog.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx (100%) rename web/{default => }/src/features/channels/components/dialogs/upstream-update-dialog.tsx (100%) rename web/{default => }/src/features/channels/components/drawers/channel-mutate-drawer.tsx (100%) rename web/{default => }/src/features/channels/components/drawers/sections/channel-advanced-section.tsx (78%) rename web/{default => }/src/features/channels/components/drawers/sections/channel-api-access-section.tsx (100%) rename web/{default => }/src/features/channels/components/drawers/sections/channel-auth-section.tsx (100%) rename web/{default => }/src/features/channels/components/drawers/sections/channel-basic-section.tsx (100%) rename web/{default => }/src/features/channels/components/drawers/sections/channel-editor-loading-state.tsx (100%) rename web/{default => }/src/features/channels/components/drawers/sections/channel-models-section.tsx (100%) rename web/{default => }/src/features/channels/components/drawers/sections/index.ts (100%) rename web/{default => }/src/features/channels/components/model-mapping-editor.tsx (100%) rename web/{default => }/src/features/channels/components/numeric-spinner-input.tsx (100%) rename web/{default => }/src/features/channels/constants.ts (100%) rename web/{default => }/src/features/channels/hooks/use-channel-mutate-form.ts (100%) rename web/{default => }/src/features/channels/hooks/use-channel-upstream-updates.ts (100%) rename web/{default => }/src/features/channels/index.tsx (100%) rename web/{default => }/src/features/channels/lib/advanced-custom.ts (100%) rename web/{default => }/src/features/channels/lib/channel-actions.ts (100%) rename web/{default => }/src/features/channels/lib/channel-form-errors.ts (100%) rename web/{default => }/src/features/channels/lib/channel-form.ts (100%) rename web/{default => }/src/features/channels/lib/channel-type-config.ts (100%) rename web/{default => }/src/features/channels/lib/channel-utils.ts (100%) rename web/{default => }/src/features/channels/lib/index.ts (100%) rename web/{default => }/src/features/channels/lib/model-mapping-validation.ts (100%) rename web/{default => }/src/features/channels/lib/multi-key-utils.ts (100%) rename web/{default => }/src/features/channels/lib/ollama-utils.ts (100%) rename web/{default => }/src/features/channels/lib/status-code-risk-guard.ts (100%) rename web/{default => }/src/features/channels/lib/upstream-update-utils.ts (100%) rename web/{default => }/src/features/channels/types.ts (100%) rename web/{default => }/src/features/chat/hooks/use-active-chat-key.ts (100%) rename web/{default => }/src/features/chat/hooks/use-chat-presets.ts (100%) rename web/{default => }/src/features/chat/lib/chat-links.ts (100%) rename web/{default => }/src/features/chat/lib/send-to-fluent.ts (100%) rename web/{default => }/src/features/dashboard/api.ts (97%) rename web/{default => }/src/features/dashboard/components/flow/flow-charts.tsx (100%) rename web/{default => }/src/features/dashboard/components/flow/flow-node-filter.tsx (100%) rename web/{default => }/src/features/dashboard/components/models/consumption-distribution-chart.tsx (100%) rename web/{default => }/src/features/dashboard/components/models/log-stat-cards.tsx (100%) rename web/{default => }/src/features/dashboard/components/models/model-charts.tsx (100%) rename web/{default => }/src/features/dashboard/components/models/models-chart-preferences.tsx (100%) rename web/{default => }/src/features/dashboard/components/models/models-filter-dialog.tsx (100%) rename web/{default => }/src/features/dashboard/components/models/performance-overview.tsx (100%) rename web/{default => }/src/features/dashboard/components/overview/announcement-detail-dialog.tsx (100%) rename web/{default => }/src/features/dashboard/components/overview/announcements-panel.tsx (100%) rename web/{default => }/src/features/dashboard/components/overview/api-info-item.tsx (100%) rename web/{default => }/src/features/dashboard/components/overview/api-info-panel.tsx (100%) rename web/{default => }/src/features/dashboard/components/overview/faq-panel.tsx (100%) rename web/{default => }/src/features/dashboard/components/overview/overview-dashboard.tsx (100%) rename web/{default => }/src/features/dashboard/components/overview/performance-health-panel.tsx (100%) rename web/{default => }/src/features/dashboard/components/overview/summary-cards.tsx (100%) rename web/{default => }/src/features/dashboard/components/overview/uptime-panel.tsx (100%) rename web/{default => }/src/features/dashboard/components/ui/panel-wrapper.tsx (100%) rename web/{default => }/src/features/dashboard/components/ui/stat-card.tsx (100%) rename web/{default => }/src/features/dashboard/components/users/user-charts.tsx (100%) rename web/{default => }/src/features/dashboard/constants.ts (100%) rename web/{default => }/src/features/dashboard/hooks/use-dashboard-config.tsx (100%) rename web/{default => }/src/features/dashboard/hooks/use-status-data.ts (100%) rename web/{default => }/src/features/dashboard/index.tsx (100%) rename web/{default => }/src/features/dashboard/lib/api-info.ts (100%) rename web/{default => }/src/features/dashboard/lib/charts.ts (100%) rename web/{default => }/src/features/dashboard/lib/filters.ts (100%) rename web/{default => }/src/features/dashboard/lib/flow-selection.test.ts (78%) rename web/{default => }/src/features/dashboard/lib/flow-selection.ts (100%) rename web/{default => }/src/features/dashboard/lib/flow.test.ts (96%) rename web/{default => }/src/features/dashboard/lib/flow.ts (100%) rename web/{default => }/src/features/dashboard/lib/index.ts (100%) rename web/{default => }/src/features/dashboard/lib/stats.ts (100%) rename web/{default => }/src/features/dashboard/lib/text.ts (100%) rename web/{default => }/src/features/dashboard/section-registry.tsx (100%) rename web/{default => }/src/features/dashboard/types.ts (100%) rename web/{default => }/src/features/errors/forbidden.tsx (100%) rename web/{default => }/src/features/errors/general-error.tsx (100%) rename web/{default => }/src/features/errors/maintenance-error.tsx (100%) rename web/{default => }/src/features/errors/not-found-error.tsx (100%) rename web/{default => }/src/features/errors/unauthorized-error.tsx (100%) rename web/{default => }/src/features/home/api.ts (100%) rename web/{default => }/src/features/home/components/connection-line.tsx (100%) rename web/{default => }/src/features/home/components/feature-item.tsx (100%) rename web/{default => }/src/features/home/components/gateway-card.tsx (100%) rename web/{default => }/src/features/home/components/hero-buttons.tsx (100%) rename web/{default => }/src/features/home/components/hero-terminal-demo.tsx (100%) rename web/{default => }/src/features/home/components/icon-card.tsx (100%) rename web/{default => }/src/features/home/components/index.ts (100%) rename web/{default => }/src/features/home/components/scrolling-icons.tsx (100%) rename web/{default => }/src/features/home/components/sections/cta.tsx (100%) rename web/{default => }/src/features/home/components/sections/features.tsx (100%) rename web/{default => }/src/features/home/components/sections/hero.tsx (100%) rename web/{default => }/src/features/home/components/sections/how-it-works.tsx (100%) rename web/{default => }/src/features/home/components/sections/stats.tsx (100%) rename web/{default => }/src/features/home/components/stat-item.tsx (100%) rename web/{default => }/src/features/home/constants.ts (100%) rename web/{default => }/src/features/home/hooks/index.ts (100%) rename web/{default => }/src/features/home/hooks/use-home-page-content.ts (100%) rename web/{default => }/src/features/home/index.tsx (100%) rename web/{default => }/src/features/home/lib/icon-mapper.tsx (100%) rename web/{default => }/src/features/home/types.ts (100%) rename web/{default => }/src/features/keys/api.ts (100%) rename web/{default => }/src/features/keys/components/api-key-group-combobox.tsx (100%) rename web/{default => }/src/features/keys/components/api-key-timestamp-cell.tsx (100%) rename web/{default => }/src/features/keys/components/api-keys-cells.tsx (100%) rename web/{default => }/src/features/keys/components/api-keys-columns.tsx (100%) rename web/{default => }/src/features/keys/components/api-keys-delete-dialog.tsx (100%) rename web/{default => }/src/features/keys/components/api-keys-dialogs.tsx (100%) rename web/{default => }/src/features/keys/components/api-keys-multi-delete-dialog.tsx (100%) rename web/{default => }/src/features/keys/components/api-keys-mutate-drawer.tsx (100%) rename web/{default => }/src/features/keys/components/api-keys-primary-buttons.tsx (100%) rename web/{default => }/src/features/keys/components/api-keys-provider.tsx (100%) rename web/{default => }/src/features/keys/components/api-keys-table.tsx (100%) rename web/{default => }/src/features/keys/components/data-table-bulk-actions.tsx (100%) rename web/{default => }/src/features/keys/components/data-table-row-actions.tsx (100%) rename web/{default => }/src/features/keys/components/dialogs/cc-switch-dialog.tsx (100%) rename web/{default => }/src/features/keys/constants.ts (100%) rename web/{default => }/src/features/keys/index.tsx (100%) rename web/{default => }/src/features/keys/lib/api-key-form.ts (100%) rename web/{default => }/src/features/keys/lib/index.ts (100%) rename web/{default => }/src/features/keys/types.ts (100%) rename web/{default => }/src/features/legal/api.ts (100%) rename web/{default => }/src/features/legal/index.ts (100%) rename web/{default => }/src/features/legal/legal-document.tsx (97%) rename web/{default => }/src/features/legal/privacy-policy.tsx (100%) rename web/{default => }/src/features/legal/types.ts (100%) rename web/{default => }/src/features/legal/user-agreement.tsx (100%) rename web/{default => }/src/features/models/api.ts (100%) rename web/{default => }/src/features/models/components/data-table-bulk-actions.tsx (100%) rename web/{default => }/src/features/models/components/data-table-row-actions.tsx (100%) rename web/{default => }/src/features/models/components/deployment-access-guard.tsx (100%) rename web/{default => }/src/features/models/components/deployments-columns.tsx (100%) rename web/{default => }/src/features/models/components/deployments-table.tsx (100%) rename web/{default => }/src/features/models/components/description-cell.tsx (100%) rename web/{default => }/src/features/models/components/dialogs/create-deployment-drawer.tsx (100%) rename web/{default => }/src/features/models/components/dialogs/description-dialog.tsx (100%) rename web/{default => }/src/features/models/components/dialogs/extend-deployment-dialog.tsx (100%) rename web/{default => }/src/features/models/components/dialogs/missing-models-dialog.tsx (100%) rename web/{default => }/src/features/models/components/dialogs/prefill-group-management-dialog.tsx (100%) rename web/{default => }/src/features/models/components/dialogs/prefill-group-management.tsx (100%) rename web/{default => }/src/features/models/components/dialogs/rename-deployment-dialog.tsx (100%) rename web/{default => }/src/features/models/components/dialogs/sync-wizard-dialog.tsx (100%) rename web/{default => }/src/features/models/components/dialogs/update-config-dialog.tsx (100%) rename web/{default => }/src/features/models/components/dialogs/upstream-conflict-dialog.tsx (100%) rename web/{default => }/src/features/models/components/dialogs/vendor-mutate-dialog.tsx (100%) rename web/{default => }/src/features/models/components/dialogs/view-details-dialog.tsx (93%) rename web/{default => }/src/features/models/components/dialogs/view-logs-dialog.tsx (93%) rename web/{default => }/src/features/models/components/drawers/model-mutate-drawer.tsx (100%) rename web/{default => }/src/features/models/components/drawers/prefill-group-form-drawer.tsx (100%) rename web/{default => }/src/features/models/components/models-columns.tsx (100%) rename web/{default => }/src/features/models/components/models-dialogs.tsx (100%) rename web/{default => }/src/features/models/components/models-primary-buttons.tsx (100%) rename web/{default => }/src/features/models/components/models-provider.tsx (100%) rename web/{default => }/src/features/models/components/models-table.tsx (98%) rename web/{default => }/src/features/models/components/prefill-group-shared.ts (100%) rename web/{default => }/src/features/models/constants.ts (100%) rename web/{default => }/src/features/models/hooks/use-model-deployment-settings.ts (100%) rename web/{default => }/src/features/models/index.tsx (100%) rename web/{default => }/src/features/models/lib/deployments-utils.ts (100%) rename web/{default => }/src/features/models/lib/index.ts (100%) rename web/{default => }/src/features/models/lib/model-actions.ts (100%) rename web/{default => }/src/features/models/lib/model-form.ts (100%) rename web/{default => }/src/features/models/lib/model-utils.ts (100%) rename web/{default => }/src/features/models/lib/query-keys.ts (100%) rename web/{default => }/src/features/models/lib/vendor-actions.ts (100%) rename web/{default => }/src/features/models/section-registry.tsx (100%) rename web/{default => }/src/features/models/types.ts (100%) rename web/{default => }/src/features/performance-metrics/api.ts (100%) rename web/{default => }/src/features/performance-metrics/lib/format.ts (100%) rename web/{default => }/src/features/performance-metrics/types.ts (100%) rename web/{default => }/src/features/playground/api.ts (100%) rename web/{default => }/src/features/playground/components/chat/playground-chat.tsx (100%) rename web/{default => }/src/features/playground/components/chat/playground-empty-state.tsx (100%) rename web/{default => }/src/features/playground/components/input/playground-input-controls.tsx (100%) rename web/{default => }/src/features/playground/components/input/playground-input-tools.tsx (100%) rename web/{default => }/src/features/playground/components/input/playground-input.tsx (100%) rename web/{default => }/src/features/playground/components/input/playground-parameter-panel.tsx (100%) rename web/{default => }/src/features/playground/components/message/message-action-button.tsx (100%) rename web/{default => }/src/features/playground/components/message/message-actions.tsx (100%) rename web/{default => }/src/features/playground/components/message/message-error-actions.tsx (100%) rename web/{default => }/src/features/playground/components/message/message-error.tsx (80%) rename web/{default => }/src/features/playground/components/message/message-metadata.tsx (100%) rename web/{default => }/src/features/playground/components/message/playground-message-content.tsx (100%) rename web/{default => }/src/features/playground/components/message/playground-message-editor.tsx (100%) rename web/{default => }/src/features/playground/constants.ts (100%) rename web/{default => }/src/features/playground/hooks/index.ts (100%) rename web/{default => }/src/features/playground/hooks/use-chat-handler.ts (100%) rename web/{default => }/src/features/playground/hooks/use-message-action-guard.ts (100%) rename web/{default => }/src/features/playground/hooks/use-playground-conversation.ts (100%) rename web/{default => }/src/features/playground/hooks/use-playground-options.ts (81%) rename web/{default => }/src/features/playground/hooks/use-playground-state.ts (100%) rename web/{default => }/src/features/playground/hooks/use-stream-request.test.ts (100%) rename web/{default => }/src/features/playground/hooks/use-stream-request.ts (100%) rename web/{default => }/src/features/playground/index.tsx (100%) rename web/{default => }/src/features/playground/lib/index.ts (100%) rename web/{default => }/src/features/playground/lib/input/input-control-utils.ts (100%) rename web/{default => }/src/features/playground/lib/input/input-tool-utils.ts (100%) rename web/{default => }/src/features/playground/lib/message/conversation-message-utils.ts (84%) rename web/{default => }/src/features/playground/lib/message/message-action-utils.ts (100%) rename web/{default => }/src/features/playground/lib/message/message-content-utils.ts (100%) rename web/{default => }/src/features/playground/lib/message/message-editor-utils.ts (100%) rename web/{default => }/src/features/playground/lib/message/message-error-utils.ts (100%) rename web/{default => }/src/features/playground/lib/message/message-layout-utils.ts (100%) rename web/{default => }/src/features/playground/lib/message/message-reasoning-utils.ts (99%) rename web/{default => }/src/features/playground/lib/message/message-streaming-utils.ts (100%) rename web/{default => }/src/features/playground/lib/message/message-styles.ts (100%) rename web/{default => }/src/features/playground/lib/message/message-timing-utils.ts (100%) rename web/{default => }/src/features/playground/lib/message/message-update-utils.ts (100%) rename web/{default => }/src/features/playground/lib/message/message-utils.ts (100%) rename web/{default => }/src/features/playground/lib/options/playground-option-utils.ts (100%) rename web/{default => }/src/features/playground/lib/parameters/playground-parameters.ts (100%) rename web/{default => }/src/features/playground/lib/state/playground-state-utils.ts (100%) rename web/{default => }/src/features/playground/lib/storage/storage-schema.ts (100%) rename web/{default => }/src/features/playground/lib/storage/storage.ts (100%) rename web/{default => }/src/features/playground/lib/streaming/payload-builder.ts (100%) rename web/{default => }/src/features/playground/lib/streaming/request-error-utils.ts (100%) rename web/{default => }/src/features/playground/lib/streaming/stream-utils.ts (100%) rename web/{default => }/src/features/playground/types.ts (100%) rename web/{default => }/src/features/pricing/api.ts (100%) rename web/{default => }/src/features/pricing/components/dynamic-pricing-breakdown.tsx (100%) rename web/{default => }/src/features/pricing/components/empty-state.tsx (100%) rename web/{default => }/src/features/pricing/components/index.ts (100%) rename web/{default => }/src/features/pricing/components/loading-skeleton.tsx (100%) rename web/{default => }/src/features/pricing/components/model-billing-mode-badge.tsx (100%) rename web/{default => }/src/features/pricing/components/model-card-grid.tsx (100%) rename web/{default => }/src/features/pricing/components/model-card.tsx (100%) rename web/{default => }/src/features/pricing/components/model-details-api.tsx (100%) rename web/{default => }/src/features/pricing/components/model-details-apps.tsx (100%) rename web/{default => }/src/features/pricing/components/model-details-charts.tsx (100%) rename web/{default => }/src/features/pricing/components/model-details-performance.tsx (100%) rename web/{default => }/src/features/pricing/components/model-details-uptime-sparkline.tsx (100%) rename web/{default => }/src/features/pricing/components/model-details.tsx (100%) rename web/{default => }/src/features/pricing/components/model-perf-badge.tsx (100%) rename web/{default => }/src/features/pricing/components/pricing-columns.tsx (100%) rename web/{default => }/src/features/pricing/components/pricing-sidebar.tsx (100%) rename web/{default => }/src/features/pricing/components/pricing-table.tsx (100%) rename web/{default => }/src/features/pricing/components/pricing-toolbar.tsx (100%) rename web/{default => }/src/features/pricing/components/search-bar.tsx (100%) rename web/{default => }/src/features/pricing/constants.ts (100%) rename web/{default => }/src/features/pricing/hooks/index.ts (100%) rename web/{default => }/src/features/pricing/hooks/use-filters.ts (100%) rename web/{default => }/src/features/pricing/hooks/use-pricing-data.ts (100%) rename web/{default => }/src/features/pricing/index.tsx (100%) rename web/{default => }/src/features/pricing/lib/billing-expr.ts (99%) rename web/{default => }/src/features/pricing/lib/dynamic-price.ts (100%) rename web/{default => }/src/features/pricing/lib/filters.ts (100%) rename web/{default => }/src/features/pricing/lib/index.ts (100%) rename web/{default => }/src/features/pricing/lib/mock-stats.ts (100%) rename web/{default => }/src/features/pricing/lib/model-helpers.ts (96%) rename web/{default => }/src/features/pricing/lib/price.ts (100%) rename web/{default => }/src/features/pricing/lib/seed.ts (100%) rename web/{default => }/src/features/pricing/lib/tier-expr.ts (100%) rename web/{default => }/src/features/pricing/types.ts (100%) rename web/{default => }/src/features/profile/api.ts (97%) rename web/{default => }/src/features/profile/components/checkin-calendar-card.tsx (100%) rename web/{default => }/src/features/profile/components/dialogs/access-token-dialog.tsx (100%) rename web/{default => }/src/features/profile/components/dialogs/change-password-dialog.tsx (100%) rename web/{default => }/src/features/profile/components/dialogs/delete-account-dialog.tsx (100%) rename web/{default => }/src/features/profile/components/dialogs/email-bind-dialog.tsx (100%) rename web/{default => }/src/features/profile/components/dialogs/telegram-bind-dialog.tsx (100%) rename web/{default => }/src/features/profile/components/dialogs/two-fa-backup-dialog.tsx (100%) rename web/{default => }/src/features/profile/components/dialogs/two-fa-disable-dialog.tsx (100%) rename web/{default => }/src/features/profile/components/dialogs/two-fa-setup-dialog.tsx (100%) create mode 100644 web/src/features/profile/components/dialogs/wechat-bind-dialog.tsx rename web/{default => }/src/features/profile/components/language-preferences-card.tsx (100%) rename web/{default => }/src/features/profile/components/login-session-dialogs.tsx (100%) rename web/{default => }/src/features/profile/components/login-session-item.tsx (100%) rename web/{default => }/src/features/profile/components/login-session-utils.test.ts (100%) rename web/{default => }/src/features/profile/components/login-session-utils.ts (100%) rename web/{default => }/src/features/profile/components/login-sessions-card.tsx (100%) rename web/{default => }/src/features/profile/components/passkey-card.tsx (100%) rename web/{default => }/src/features/profile/components/profile-header.tsx (100%) rename web/{default => }/src/features/profile/components/profile-security-card.tsx (100%) rename web/{default => }/src/features/profile/components/profile-settings-card.tsx (100%) rename web/{default => }/src/features/profile/components/sidebar-modules-card.tsx (100%) rename web/{default => }/src/features/profile/components/tabs/account-bindings-tab.tsx (99%) rename web/{default => }/src/features/profile/components/tabs/notification-tab.tsx (100%) rename web/{default => }/src/features/profile/components/two-fa-card.tsx (100%) rename web/{default => }/src/features/profile/constants.ts (100%) rename web/{default => }/src/features/profile/hooks/index.ts (100%) rename web/{default => }/src/features/profile/hooks/use-access-token.ts (100%) rename web/{default => }/src/features/profile/hooks/use-profile.ts (100%) rename web/{default => }/src/features/profile/hooks/use-two-fa.ts (100%) rename web/{default => }/src/features/profile/index.tsx (100%) rename web/{default => }/src/features/profile/lib/format.ts (100%) rename web/{default => }/src/features/profile/lib/index.ts (100%) rename web/{default => }/src/features/profile/types.ts (100%) rename web/{default => }/src/features/rankings/api.ts (100%) rename web/{default => }/src/features/rankings/components/entity-links.tsx (100%) rename web/{default => }/src/features/rankings/components/growth-text.tsx (100%) rename web/{default => }/src/features/rankings/components/index.ts (100%) rename web/{default => }/src/features/rankings/components/market-share-section.tsx (100%) rename web/{default => }/src/features/rankings/components/model-leaderboard.tsx (100%) rename web/{default => }/src/features/rankings/components/models-section.tsx (100%) rename web/{default => }/src/features/rankings/components/pulse-section.tsx (100%) rename web/{default => }/src/features/rankings/components/rankings-hero.tsx (100%) rename web/{default => }/src/features/rankings/hooks/use-rankings.ts (100%) rename web/{default => }/src/features/rankings/index.tsx (100%) rename web/{default => }/src/features/rankings/lib/format.ts (100%) rename web/{default => }/src/features/rankings/lib/index.ts (100%) rename web/{default => }/src/features/rankings/types.ts (100%) rename web/{default => }/src/features/redemption-codes/api.ts (100%) rename web/{default => }/src/features/redemption-codes/components/data-table-bulk-actions.tsx (100%) rename web/{default => }/src/features/redemption-codes/components/data-table-row-actions.tsx (100%) rename web/{default => }/src/features/redemption-codes/components/redemptions-columns.tsx (100%) rename web/{default => }/src/features/redemption-codes/components/redemptions-delete-dialog.tsx (100%) rename web/{default => }/src/features/redemption-codes/components/redemptions-dialogs.tsx (100%) rename web/{default => }/src/features/redemption-codes/components/redemptions-mobile-list.tsx (100%) rename web/{default => }/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx (100%) rename web/{default => }/src/features/redemption-codes/components/redemptions-primary-buttons.tsx (100%) rename web/{default => }/src/features/redemption-codes/components/redemptions-provider.tsx (100%) rename web/{default => }/src/features/redemption-codes/components/redemptions-table.tsx (100%) rename web/{default => }/src/features/redemption-codes/constants.ts (100%) rename web/{default => }/src/features/redemption-codes/index.tsx (100%) rename web/{default => }/src/features/redemption-codes/lib/index.ts (100%) rename web/{default => }/src/features/redemption-codes/lib/redemption-form.ts (100%) rename web/{default => }/src/features/redemption-codes/lib/utils.ts (100%) rename web/{default => }/src/features/redemption-codes/types.ts (100%) rename web/{default => }/src/features/setup/api.ts (100%) rename web/{default => }/src/features/setup/components/admin-step.tsx (100%) rename web/{default => }/src/features/setup/components/complete-step.tsx (100%) rename web/{default => }/src/features/setup/components/database-step.tsx (100%) rename web/{default => }/src/features/setup/components/step-navigation.tsx (100%) rename web/{default => }/src/features/setup/components/usage-mode-step.tsx (100%) rename web/{default => }/src/features/setup/index.ts (100%) rename web/{default => }/src/features/setup/setup-wizard.tsx (100%) rename web/{default => }/src/features/setup/types.ts (100%) rename web/{default => }/src/features/subscriptions/api.ts (100%) rename web/{default => }/src/features/subscriptions/components/data-table-row-actions.tsx (100%) rename web/{default => }/src/features/subscriptions/components/dialogs/reset-subscriptions-dialog.tsx (100%) rename web/{default => }/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx (100%) rename web/{default => }/src/features/subscriptions/components/dialogs/toggle-status-dialog.tsx (100%) rename web/{default => }/src/features/subscriptions/components/dialogs/user-subscriptions-dialog.tsx (99%) rename web/{default => }/src/features/subscriptions/components/subscriptions-columns.tsx (100%) rename web/{default => }/src/features/subscriptions/components/subscriptions-dialogs.tsx (100%) rename web/{default => }/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx (100%) rename web/{default => }/src/features/subscriptions/components/subscriptions-primary-buttons.tsx (100%) rename web/{default => }/src/features/subscriptions/components/subscriptions-provider.tsx (100%) rename web/{default => }/src/features/subscriptions/components/subscriptions-table.tsx (100%) rename web/{default => }/src/features/subscriptions/constants.ts (100%) rename web/{default => }/src/features/subscriptions/index.tsx (100%) rename web/{default => }/src/features/subscriptions/lib/format.ts (100%) rename web/{default => }/src/features/subscriptions/lib/index.ts (100%) rename web/{default => }/src/features/subscriptions/lib/plan-form.ts (100%) rename web/{default => }/src/features/subscriptions/types.ts (100%) rename web/{default => }/src/features/system-info/api.ts (100%) rename web/{default => }/src/features/system-info/components/system-instances-panel.tsx (100%) rename web/{default => }/src/features/system-info/components/system-tasks-panel.tsx (100%) rename web/{default => }/src/features/system-info/index.tsx (100%) rename web/{default => }/src/features/system-info/types.ts (100%) rename web/{default => }/src/features/system-settings/api.ts (100%) rename web/{default => }/src/features/system-settings/auth/basic-auth-section.tsx (100%) rename web/{default => }/src/features/system-settings/auth/bot-protection-section.tsx (100%) rename web/{default => }/src/features/system-settings/auth/custom-oauth/api.ts (100%) rename web/{default => }/src/features/system-settings/auth/custom-oauth/components/discovery-button.tsx (100%) rename web/{default => }/src/features/system-settings/auth/custom-oauth/components/preset-selector.tsx (100%) rename web/{default => }/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx (100%) rename web/{default => }/src/features/system-settings/auth/custom-oauth/components/provider-table.tsx (100%) rename web/{default => }/src/features/system-settings/auth/custom-oauth/custom-oauth-section.tsx (100%) rename web/{default => }/src/features/system-settings/auth/custom-oauth/hooks/use-custom-oauth-mutations.ts (100%) rename web/{default => }/src/features/system-settings/auth/custom-oauth/hooks/use-custom-oauth-providers.ts (100%) rename web/{default => }/src/features/system-settings/auth/custom-oauth/types.ts (100%) rename web/{default => }/src/features/system-settings/auth/index.tsx (100%) rename web/{default => }/src/features/system-settings/auth/oauth-callback-url.ts (99%) rename web/{default => }/src/features/system-settings/auth/oauth-section.tsx (100%) rename web/{default => }/src/features/system-settings/auth/passkey-section.tsx (100%) rename web/{default => }/src/features/system-settings/auth/section-registry.tsx (100%) rename web/{default => }/src/features/system-settings/billing/index.tsx (100%) rename web/{default => }/src/features/system-settings/billing/section-registry.tsx (100%) rename web/{default => }/src/features/system-settings/components/form-dirty-indicator.tsx (100%) rename web/{default => }/src/features/system-settings/components/form-navigation-guard.tsx (100%) rename web/{default => }/src/features/system-settings/components/settings-accordion.tsx (100%) rename web/{default => }/src/features/system-settings/components/settings-card.tsx (100%) rename web/{default => }/src/features/system-settings/components/settings-form-layout.tsx (100%) rename web/{default => }/src/features/system-settings/components/settings-page-context.tsx (100%) rename web/{default => }/src/features/system-settings/components/settings-page.tsx (100%) rename web/{default => }/src/features/system-settings/components/settings-section.tsx (100%) rename web/{default => }/src/features/system-settings/content/announcements-section.tsx (100%) rename web/{default => }/src/features/system-settings/content/api-info-section.tsx (100%) rename web/{default => }/src/features/system-settings/content/chat-dialog.tsx (100%) rename web/{default => }/src/features/system-settings/content/chat-settings-section.tsx (100%) rename web/{default => }/src/features/system-settings/content/chat-settings-visual-editor.tsx (100%) rename web/{default => }/src/features/system-settings/content/dashboard-section.tsx (100%) rename web/{default => }/src/features/system-settings/content/drawing-settings-section.tsx (100%) rename web/{default => }/src/features/system-settings/content/faq-section.tsx (100%) rename web/{default => }/src/features/system-settings/content/index.tsx (100%) rename web/{default => }/src/features/system-settings/content/json-toggle-section.tsx (100%) rename web/{default => }/src/features/system-settings/content/section-registry.tsx (100%) rename web/{default => }/src/features/system-settings/content/uptime-kuma-section.tsx (100%) rename web/{default => }/src/features/system-settings/content/utils.ts (100%) rename web/{default => }/src/features/system-settings/general/channel-affinity/api.ts (100%) rename web/{default => }/src/features/system-settings/general/channel-affinity/cache-stats-dialog.tsx (100%) rename web/{default => }/src/features/system-settings/general/channel-affinity/constants.ts (100%) rename web/{default => }/src/features/system-settings/general/channel-affinity/index.tsx (100%) rename web/{default => }/src/features/system-settings/general/channel-affinity/rule-editor-dialog.tsx (100%) rename web/{default => }/src/features/system-settings/general/channel-affinity/types.ts (100%) rename web/{default => }/src/features/system-settings/general/checkin-settings-section.tsx (100%) rename web/{default => }/src/features/system-settings/general/pricing-section.tsx (100%) rename web/{default => }/src/features/system-settings/general/quota-settings-section.tsx (100%) rename web/{default => }/src/features/system-settings/general/system-behavior-section.tsx (100%) rename web/{default => }/src/features/system-settings/general/system-info-section.tsx (74%) rename web/{default => }/src/features/system-settings/hooks/use-accordion-state.ts (100%) rename web/{default => }/src/features/system-settings/hooks/use-form-dirty-guard.ts (100%) rename web/{default => }/src/features/system-settings/hooks/use-reset-form.ts (100%) rename web/{default => }/src/features/system-settings/hooks/use-safe-json-state.ts (100%) rename web/{default => }/src/features/system-settings/hooks/use-settings-form.ts (100%) rename web/{default => }/src/features/system-settings/hooks/use-system-options.ts (100%) rename web/{default => }/src/features/system-settings/hooks/use-update-option.ts (99%) rename web/{default => }/src/features/system-settings/index.tsx (100%) rename web/{default => }/src/features/system-settings/integrations/amount-discount-dialog.tsx (100%) rename web/{default => }/src/features/system-settings/integrations/amount-discount-visual-editor.tsx (100%) rename web/{default => }/src/features/system-settings/integrations/amount-options-visual-editor.tsx (100%) rename web/{default => }/src/features/system-settings/integrations/creem-product-dialog.tsx (100%) rename web/{default => }/src/features/system-settings/integrations/creem-products-visual-editor.tsx (100%) rename web/{default => }/src/features/system-settings/integrations/email-settings-section.tsx (100%) rename web/{default => }/src/features/system-settings/integrations/ionet-deployment-settings-section.tsx (100%) rename web/{default => }/src/features/system-settings/integrations/monitoring-settings-section.tsx (100%) rename web/{default => }/src/features/system-settings/integrations/payment-method-dialog.tsx (100%) rename web/{default => }/src/features/system-settings/integrations/payment-methods-visual-editor.tsx (100%) rename web/{default => }/src/features/system-settings/integrations/payment-settings-section.tsx (100%) rename web/{default => }/src/features/system-settings/integrations/utils.ts (100%) rename web/{default => }/src/features/system-settings/integrations/waffo-pancake-api.ts (100%) rename web/{default => }/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx (99%) rename web/{default => }/src/features/system-settings/integrations/waffo-settings-section.tsx (99%) rename web/{default => }/src/features/system-settings/integrations/worker-settings-section.tsx (100%) rename web/{default => }/src/features/system-settings/maintenance/config.ts (100%) rename web/{default => }/src/features/system-settings/maintenance/header-navigation-section.tsx (100%) rename web/{default => }/src/features/system-settings/maintenance/log-settings-section.tsx (100%) rename web/{default => }/src/features/system-settings/maintenance/notice-section.tsx (100%) rename web/{default => }/src/features/system-settings/maintenance/performance-section.tsx (100%) rename web/{default => }/src/features/system-settings/maintenance/sidebar-modules-section.tsx (100%) rename web/{default => }/src/features/system-settings/maintenance/update-checker-section.tsx (100%) rename web/{default => }/src/features/system-settings/models/channel-selector-dialog.tsx (100%) rename web/{default => }/src/features/system-settings/models/claude-settings-card.tsx (100%) rename web/{default => }/src/features/system-settings/models/conflict-confirm-dialog.tsx (100%) rename web/{default => }/src/features/system-settings/models/constants.ts (96%) rename web/{default => }/src/features/system-settings/models/gemini-settings-card.tsx (100%) rename web/{default => }/src/features/system-settings/models/global-settings-card.tsx (100%) rename web/{default => }/src/features/system-settings/models/grok-settings-card.tsx (100%) rename web/{default => }/src/features/system-settings/models/group-ratio-form.tsx (96%) rename web/{default => }/src/features/system-settings/models/group-ratio-visual-editor.tsx (99%) rename web/{default => }/src/features/system-settings/models/group-special-usable-editor.tsx (100%) rename web/{default => }/src/features/system-settings/models/index.tsx (100%) rename web/{default => }/src/features/system-settings/models/model-pricing-core.ts (100%) rename web/{default => }/src/features/system-settings/models/model-pricing-inputs.tsx (100%) rename web/{default => }/src/features/system-settings/models/model-pricing-sheet.tsx (100%) rename web/{default => }/src/features/system-settings/models/model-pricing-snapshots.ts (100%) rename web/{default => }/src/features/system-settings/models/model-ratio-form.tsx (100%) rename web/{default => }/src/features/system-settings/models/model-ratio-table-columns.tsx (100%) rename web/{default => }/src/features/system-settings/models/model-ratio-visual-editor.tsx (100%) rename web/{default => }/src/features/system-settings/models/pricing-format.ts (100%) rename web/{default => }/src/features/system-settings/models/ratio-settings-card.tsx (100%) rename web/{default => }/src/features/system-settings/models/routing-reliability-section.tsx (100%) rename web/{default => }/src/features/system-settings/models/section-registry.tsx (100%) rename web/{default => }/src/features/system-settings/models/tiered-pricing-editor.tsx (99%) rename web/{default => }/src/features/system-settings/models/tool-price-settings.tsx (100%) rename web/{default => }/src/features/system-settings/models/upstream-ratio-sync-columns.tsx (100%) rename web/{default => }/src/features/system-settings/models/upstream-ratio-sync-helpers.ts (100%) rename web/{default => }/src/features/system-settings/models/upstream-ratio-sync-table.tsx (100%) rename web/{default => }/src/features/system-settings/models/upstream-ratio-sync.tsx (100%) rename web/{default => }/src/features/system-settings/models/utils.ts (100%) rename web/{default => }/src/features/system-settings/operations/index.tsx (100%) rename web/{default => }/src/features/system-settings/operations/section-registry.tsx (100%) rename web/{default => }/src/features/system-settings/request-limits/rate-limit-dialog.tsx (100%) rename web/{default => }/src/features/system-settings/request-limits/rate-limit-section.tsx (100%) rename web/{default => }/src/features/system-settings/request-limits/rate-limit-visual-editor.tsx (100%) rename web/{default => }/src/features/system-settings/request-limits/sensitive-words-section.tsx (100%) rename web/{default => }/src/features/system-settings/request-limits/ssrf-section.tsx (100%) rename web/{default => }/src/features/system-settings/request-limits/token-limit-section.tsx (100%) rename web/{default => }/src/features/system-settings/security/index.tsx (100%) rename web/{default => }/src/features/system-settings/security/section-registry.tsx (100%) rename web/{default => }/src/features/system-settings/site/index.tsx (98%) rename web/{default => }/src/features/system-settings/site/section-registry.tsx (97%) rename web/{default => }/src/features/system-settings/types.ts (99%) rename web/{default => }/src/features/system-settings/utils/json-parser.ts (100%) rename web/{default => }/src/features/system-settings/utils/json-validators.ts (100%) rename web/{default => }/src/features/system-settings/utils/numeric-field.ts (95%) rename web/{default => }/src/features/system-settings/utils/route-config.ts (100%) rename web/{default => }/src/features/system-settings/utils/section-registry.ts (100%) rename web/{default => }/src/features/usage-logs/api.ts (100%) rename web/{default => }/src/features/usage-logs/components/columns/column-helpers.tsx (100%) rename web/{default => }/src/features/usage-logs/components/columns/common-logs-columns.tsx (100%) rename web/{default => }/src/features/usage-logs/components/columns/drawing-logs-columns.tsx (100%) rename web/{default => }/src/features/usage-logs/components/columns/task-logs-columns.tsx (100%) rename web/{default => }/src/features/usage-logs/components/common-logs-filter-bar.tsx (100%) rename web/{default => }/src/features/usage-logs/components/common-logs-header-actions.tsx (100%) rename web/{default => }/src/features/usage-logs/components/common-logs-stats.tsx (100%) rename web/{default => }/src/features/usage-logs/components/compact-date-time-range-picker.tsx (100%) rename web/{default => }/src/features/usage-logs/components/dialogs/audio-preview-dialog.tsx (100%) rename web/{default => }/src/features/usage-logs/components/dialogs/details-dialog.tsx (97%) rename web/{default => }/src/features/usage-logs/components/dialogs/fail-reason-dialog.tsx (100%) rename web/{default => }/src/features/usage-logs/components/dialogs/image-dialog.tsx (100%) rename web/{default => }/src/features/usage-logs/components/dialogs/prompt-dialog.tsx (100%) rename web/{default => }/src/features/usage-logs/components/dialogs/user-info-dialog.tsx (100%) rename web/{default => }/src/features/usage-logs/components/logs-filter-toolbar.tsx (98%) rename web/{default => }/src/features/usage-logs/components/model-badge.tsx (100%) rename web/{default => }/src/features/usage-logs/components/task-logs-filter-bar.tsx (100%) rename web/{default => }/src/features/usage-logs/components/timing-metrics-cell.tsx (96%) rename web/{default => }/src/features/usage-logs/components/usage-logs-mobile-card.tsx (100%) rename web/{default => }/src/features/usage-logs/components/usage-logs-provider.tsx (100%) rename web/{default => }/src/features/usage-logs/components/usage-logs-table.tsx (100%) rename web/{default => }/src/features/usage-logs/constants.ts (100%) rename web/{default => }/src/features/usage-logs/data/schema.ts (100%) rename web/{default => }/src/features/usage-logs/index.tsx (100%) rename web/{default => }/src/features/usage-logs/lib/columns.ts (100%) rename web/{default => }/src/features/usage-logs/lib/filter.ts (100%) rename web/{default => }/src/features/usage-logs/lib/format.ts (98%) rename web/{default => }/src/features/usage-logs/lib/index.ts (100%) rename web/{default => }/src/features/usage-logs/lib/mappers.ts (100%) rename web/{default => }/src/features/usage-logs/lib/status.ts (100%) rename web/{default => }/src/features/usage-logs/lib/utils.ts (100%) rename web/{default => }/src/features/usage-logs/section-registry.tsx (100%) rename web/{default => }/src/features/usage-logs/types.ts (100%) rename web/{default => }/src/features/users/api.ts (100%) rename web/{default => }/src/features/users/components/data-table-bulk-actions.tsx (100%) rename web/{default => }/src/features/users/components/data-table-row-actions.tsx (100%) rename web/{default => }/src/features/users/components/dialogs/user-binding-dialog.tsx (100%) rename web/{default => }/src/features/users/components/user-quota-cell.tsx (94%) rename web/{default => }/src/features/users/components/user-quota-dialog.tsx (100%) rename web/{default => }/src/features/users/components/users-columns.tsx (100%) rename web/{default => }/src/features/users/components/users-delete-dialog.tsx (100%) rename web/{default => }/src/features/users/components/users-mutate-drawer.tsx (100%) rename web/{default => }/src/features/users/components/users-primary-buttons.tsx (100%) rename web/{default => }/src/features/users/components/users-provider.tsx (100%) rename web/{default => }/src/features/users/components/users-table.tsx (100%) rename web/{default => }/src/features/users/constants.ts (100%) rename web/{default => }/src/features/users/index.tsx (100%) rename web/{default => }/src/features/users/lib/index.ts (100%) rename web/{default => }/src/features/users/lib/user-actions.ts (100%) rename web/{default => }/src/features/users/lib/user-form.ts (100%) rename web/{default => }/src/features/users/types.ts (100%) rename web/{default => }/src/features/wallet/api.ts (95%) rename web/{default => }/src/features/wallet/components/affiliate-rewards-card.tsx (100%) rename web/{default => }/src/features/wallet/components/creem-products-section.tsx (100%) rename web/{default => }/src/features/wallet/components/dialogs/billing-history-dialog.tsx (100%) rename web/{default => }/src/features/wallet/components/dialogs/creem-confirm-dialog.tsx (100%) rename web/{default => }/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx (100%) rename web/{default => }/src/features/wallet/components/dialogs/transfer-dialog.tsx (98%) rename web/{default => }/src/features/wallet/components/recharge-form-card.tsx (100%) rename web/{default => }/src/features/wallet/components/subscription-plans-card.tsx (100%) rename web/{default => }/src/features/wallet/components/wallet-stats-card.tsx (100%) rename web/{default => }/src/features/wallet/constants.ts (100%) rename web/{default => }/src/features/wallet/hooks/index.ts (100%) rename web/{default => }/src/features/wallet/hooks/use-affiliate.ts (100%) rename web/{default => }/src/features/wallet/hooks/use-billing-history.ts (100%) rename web/{default => }/src/features/wallet/hooks/use-creem-payment.ts (100%) create mode 100644 web/src/features/wallet/hooks/use-payment.test.ts rename web/{default => }/src/features/wallet/hooks/use-payment.ts (70%) rename web/{default => }/src/features/wallet/hooks/use-redemption.ts (100%) rename web/{default => }/src/features/wallet/hooks/use-topup-info.ts (100%) rename web/{default => }/src/features/wallet/hooks/use-waffo-pancake-payment.ts (100%) rename web/{default => }/src/features/wallet/hooks/use-waffo-payment.ts (98%) rename web/{default => }/src/features/wallet/index.tsx (91%) rename web/{default => }/src/features/wallet/lib/affiliate.ts (100%) rename web/{default => }/src/features/wallet/lib/billing.ts (100%) rename web/{default => }/src/features/wallet/lib/format.ts (100%) rename web/{default => }/src/features/wallet/lib/index.ts (100%) create mode 100644 web/src/features/wallet/lib/payment.test.ts rename web/{default => }/src/features/wallet/lib/payment.ts (79%) rename web/{default => }/src/features/wallet/lib/ui.tsx (100%) rename web/{default => }/src/features/wallet/types.ts (100%) rename web/{default => }/src/hooks/index.ts (100%) rename web/{default => }/src/hooks/use-admin.ts (100%) rename web/{default => }/src/hooks/use-copy-to-clipboard.ts (100%) rename web/{default => }/src/hooks/use-countdown.ts (100%) rename web/{default => }/src/hooks/use-debounce.ts (100%) rename web/{default => }/src/hooks/use-dialog.ts (100%) rename web/{default => }/src/hooks/use-hidden-click-unlock.ts (100%) rename web/{default => }/src/hooks/use-media-query.ts (100%) rename web/{default => }/src/hooks/use-minimum-loading-time.ts (100%) rename web/{default => }/src/hooks/use-mobile.ts (100%) rename web/{default => }/src/hooks/use-mobile.tsx (100%) rename web/{default => }/src/hooks/use-notifications.ts (100%) rename web/{default => }/src/hooks/use-sidebar-config.ts (100%) rename web/{default => }/src/hooks/use-sidebar-data.ts (100%) rename web/{default => }/src/hooks/use-sidebar-view.ts (100%) rename web/{default => }/src/hooks/use-status.ts (100%) rename web/{default => }/src/hooks/use-system-config.ts (100%) rename web/{default => }/src/hooks/use-table-compact-mode.ts (100%) rename web/{default => }/src/hooks/use-table-url-state.ts (98%) rename web/{default => }/src/hooks/use-top-nav-links.ts (100%) rename web/{default => }/src/hooks/use-user-display.ts (100%) rename web/{default => }/src/i18n/config.ts (99%) rename web/{default => }/src/i18n/languages.ts (93%) rename web/{default => }/src/i18n/locales/_reports/_sync-report.json (100%) rename web/{default => }/src/i18n/locales/en.json (99%) rename web/{default => }/src/i18n/locales/fr.json (99%) rename web/{default => }/src/i18n/locales/ja.json (99%) rename web/{default => }/src/i18n/locales/ru.json (99%) rename web/{default => }/src/i18n/locales/vi.json (99%) rename web/{default => }/src/i18n/locales/zh-TW.json (99%) rename web/{default => }/src/i18n/locales/zh.json (99%) rename web/{default => }/src/i18n/static-keys.ts (100%) rename web/{default => }/src/lib/admin-permissions.ts (78%) rename web/{default => }/src/lib/api.ts (100%) rename web/{default => }/src/lib/auth-session-sync.ts (100%) rename web/{default => }/src/lib/auth-session.test.ts (100%) rename web/{default => }/src/lib/auth-session.ts (100%) rename web/{default => }/src/lib/avatar.ts (100%) rename web/{default => }/src/lib/build-metadata.ts (99%) rename web/{default => }/src/lib/channel-connection-info.ts (99%) rename web/{default => }/src/lib/colors.ts (100%) rename web/{default => }/src/lib/constants.ts (100%) rename web/{default => }/src/lib/content-format.ts (100%) rename web/{default => }/src/lib/cookies.ts (100%) rename web/{default => }/src/lib/copy-to-clipboard.ts (100%) rename web/{default => }/src/lib/currency.ts (100%) rename web/{default => }/src/lib/dayjs.ts (100%) rename web/{default => }/src/lib/dom-utils.ts (100%) rename web/{default => }/src/lib/format.ts (100%) rename web/{default => }/src/lib/frontend-cache.ts (99%) rename web/{default => }/src/lib/handle-server-error.ts (100%) rename web/{default => }/src/lib/http-client.ts (100%) rename web/{default => }/src/lib/http-status-code-rules.ts (100%) create mode 100644 web/src/lib/legacy-route.test.ts create mode 100644 web/src/lib/legacy-route.ts rename web/{default => }/src/lib/lobe-icon.tsx (100%) rename web/{default => }/src/lib/motion.ts (100%) rename web/{default => }/src/lib/nav-modules.ts (100%) rename web/{default => }/src/lib/oauth.ts (100%) rename web/{default => }/src/lib/passkey.ts (100%) rename web/{default => }/src/lib/roles.ts (100%) rename web/{default => }/src/lib/secure-verification.ts (100%) rename web/{default => }/src/lib/server-error-message.test.ts (100%) rename web/{default => }/src/lib/server-error-message.ts (100%) rename web/{default => }/src/lib/show-submitted-data.tsx (100%) rename web/{default => }/src/lib/theme-customization.ts (100%) rename web/{default => }/src/lib/theme-radius.ts (100%) rename web/{default => }/src/lib/time.ts (100%) rename web/{default => }/src/lib/use-chart-theme.ts (100%) rename web/{default => }/src/lib/use-controllable-state.ts (100%) rename web/{default => }/src/lib/utils.ts (100%) rename web/{default => }/src/lib/vchart.ts (100%) rename web/{default => }/src/main.tsx (100%) rename web/{default => }/src/routeTree.gen.ts (97%) rename web/{default => }/src/routes/(auth)/forgot-password.tsx (100%) rename web/{default => }/src/routes/(auth)/oauth.tsx (100%) rename web/{default => }/src/routes/(auth)/otp.tsx (100%) rename web/{default => }/src/routes/(auth)/register.tsx (100%) rename web/{default => }/src/routes/(auth)/reset.tsx (100%) rename web/{default => }/src/routes/(auth)/route.tsx (100%) rename web/{default => }/src/routes/(auth)/sign-in.tsx (100%) rename web/{default => }/src/routes/(auth)/sign-up.tsx (100%) rename web/{default => }/src/routes/(auth)/user/reset.tsx (100%) rename web/{default => }/src/routes/(errors)/401.tsx (100%) rename web/{default => }/src/routes/(errors)/403.tsx (100%) rename web/{default => }/src/routes/(errors)/404.tsx (100%) rename web/{default => }/src/routes/(errors)/500.tsx (100%) rename web/{default => }/src/routes/(errors)/503.tsx (100%) rename web/{default => }/src/routes/__root.tsx (96%) rename web/{default => }/src/routes/_authenticated/channels/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/chat/$chatId.tsx (100%) rename web/{default => }/src/routes/_authenticated/chat2link.tsx (100%) rename web/{default => }/src/routes/_authenticated/dashboard/$section.tsx (100%) rename web/{default => }/src/routes/_authenticated/dashboard/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/errors/$error.tsx (100%) rename web/{default => }/src/routes/_authenticated/keys/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/models/$section.tsx (100%) rename web/{default => }/src/routes/_authenticated/models/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/playground/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/profile/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/redemption-codes/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/route.tsx (100%) rename web/{default => }/src/routes/_authenticated/subscriptions/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-info/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/auth/$section.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/auth/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/billing/$section.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/billing/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/content/$section.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/content/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/models/$section.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/models/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/operations/$section.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/operations/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/route.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/security/$section.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/security/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/site/$section.tsx (100%) rename web/{default => }/src/routes/_authenticated/system-settings/site/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/usage-logs/$section.tsx (100%) rename web/{default => }/src/routes/_authenticated/usage-logs/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/users/index.tsx (100%) rename web/{default => }/src/routes/_authenticated/wallet/index.tsx (100%) rename web/{default => }/src/routes/about/index.tsx (100%) rename web/{default => }/src/routes/index.tsx (100%) rename web/{default => }/src/routes/oauth/$provider.tsx (100%) rename web/{default => }/src/routes/pricing/$modelId/index.tsx (100%) rename web/{default => }/src/routes/pricing/index.tsx (100%) rename web/{default => }/src/routes/privacy-policy.tsx (100%) rename web/{default => }/src/routes/rankings/index.tsx (100%) rename web/{default => }/src/routes/setup/index.tsx (100%) rename web/{default => }/src/routes/user-agreement.tsx (100%) rename web/{default => }/src/stores/auth-store.ts (100%) rename web/{default => }/src/stores/notification-store.ts (100%) rename web/{default => }/src/stores/system-config-store.ts (100%) rename web/{default => }/src/styles/index.css (100%) rename web/{default => }/src/styles/theme-presets.css (99%) rename web/{default => }/src/styles/theme.css (97%) rename web/{default => }/src/tanstack-table.d.ts (100%) rename web/{default => }/tsconfig.app.json (100%) rename web/{default => }/tsconfig.json (100%) rename web/{default => }/tsconfig.node.json (100%) diff --git a/.agents/skills/classic-to-default-sync/SKILL.md b/.agents/skills/classic-to-default-sync/SKILL.md deleted file mode 100644 index 282e0492e158..000000000000 --- a/.agents/skills/classic-to-default-sync/SKILL.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: classic-to-default-sync -description: Inspect a given commit's web/classic changes and sync all features/fixes to web/default. Use when the user provides a commit ID and wants to audit whether web/default already has the same features as web/classic, port missing features, improve suboptimal implementations, fix bugs, and remove redundant code. Trigger phrases include: "/classic-to-default-sync ", "classic-to-default-sync ", "sync classic to default", "port from classic", "compare classic commit", "classic 和 default 对比", "把这次 classic 的修改同步到 default", "查看这次提交 classic 中的修改并同步", or any request supplying a commit hash together with classic/default comparison intent. ---- - -# Classic-to-Default Sync - -Given a **commit ID**, audit all `web/classic` changes and ensure `web/default` reaches feature parity with the best possible implementation. - -## Input - -The user must supply a ``. - -## Workflow - -### Step 1 — Extract classic diff - -```bash -git show -- web/classic -``` - -Read every changed file in `web/classic`. Identify the **logical changes** (new features, UI/UX improvements, bug fixes, config tweaks, removed dead code, etc.) — not just line diffs. - -### Step 2 — Map to default counterparts - -For each logical change found in Step 1, locate the equivalent file(s) in `web/default/src/`. Use Glob/Grep/SemanticSearch as needed. Consider that: - -- `web/classic` uses **React 18 + Vite + Semi Design** -- `web/default` uses **React 19 + Rsbuild + Base UI + Tailwind CSS** -- Component names, file paths, and API shapes may differ; match by **functionality**, not filename. - -### Step 3 — Triage each change - -Classify every logical change as one of: - -| Status | Meaning | -|--------|---------| -| ✅ Already present & optimal | No action needed | -| ⚠️ Present but suboptimal | Improve: logic, layout, style, or code quality | -| ❌ Missing | Implement from scratch in default's stack | - -### Step 4 — Implement - -For each **⚠️** or **❌** item: - -1. **Read the target file(s) in `web/default`** before editing (required by project conventions). -2. Implement using `web/default` conventions: - - React 19 patterns (hooks, Suspense, etc.) - - Base UI primitives where applicable - - Tailwind CSS for styling (no inline styles or Semi Design imports) - - `useTranslation()` + `t('English key')` for all user-visible strings - - TypeScript — explicit types, no `any` - - No dead code, no redundant comments -3. Follow **Rule 6** (pointer types for optional relay DTOs) if touching relay-related TS types. -4. After editing, run `ReadLints` on changed files and fix any introduced lint errors. - -### Step 5 — i18n - -If any new user-visible strings were added, run the i18n sync: - -```bash -cd web/default && bun run i18n:sync -``` - -Then add missing translations for all supported locales (en, zh, fr, ja, ru, vi) following the **i18n-translate** skill. - -### Step 6 — Report - -Summarise the work in a concise table: - -| # | Change (from classic commit) | Status | Action taken | -|---|------------------------------|--------|--------------| -| 1 | … | ✅ / ⚠️ / ❌ | None / Improved / Implemented | - -If every item is ✅ with no action needed, simply reply: **"已完成 — web/default 已具备此次提交的所有功能,且实现质量良好,无需修改。"** - -## Quality bar - -- No unused imports, variables, or components -- No commented-out code left behind -- Consistent naming with surrounding `web/default` code -- All interactive elements accessible (keyboard nav, ARIA labels where Radix doesn't provide them automatically) -- No regressions: existing behaviour in `web/default` must not break diff --git a/.agents/skills/i18n-translate/SKILL.md b/.agents/skills/i18n-translate/SKILL.md index 83695e1128e5..6b17ac3d6dfb 100755 --- a/.agents/skills/i18n-translate/SKILL.md +++ b/.agents/skills/i18n-translate/SKILL.md @@ -3,7 +3,7 @@ name: i18n-translate description: >- Complete and maintain frontend i18n translations for this project. Covers finding missing translation keys, detecting untranslated entries, and adding - translations for all supported locales (en, zh, fr, ja, ru, vi). Use for any + translations for all supported locales (en, zh, zh-TW, fr, ja, ru, vi). Use for any task involving frontend locale files, missing translation keys, untranslated UI text, `t(...)` keys, `useTranslation()`, static i18n keys, button/label/ toast/dialog/placeholder/validation copy, or adding/fixing even a single @@ -24,12 +24,12 @@ description: >- ### Hard Constraint: Locale Writes Go Through the Script -- You MUST NOT edit `web/default/src/i18n/locales/*.json` directly with text-editing tools (StrReplace, Write, search-and-replace, manual JSON edits, etc.). This applies even to a single key. +- You MUST NOT edit `web/src/i18n/locales/*.json` directly with text-editing tools (StrReplace, Write, search-and-replace, manual JSON edits, etc.). This applies even to a single key. - ALL locale writes MUST go through the `add-missing-keys.mjs` script, followed by `bun run i18n:sync`. The script is the only sanctioned way to add or change locale values. - Why this is mandatory, not optional: - - Hand-editing reliably drops one or more of the six locales (`en`, `zh`, `fr`, `ja`, `ru`, `vi`), leaving keys missing in some languages. + - Hand-editing reliably drops one or more of the seven locales (`en`, `zh`, `zh-TW`, `fr`, `ja`, `ru`, `vi`), leaving keys missing in some languages. - Hand-editing breaks the required alphabetical key order and introduces JSON syntax errors (trailing commas, mismatched quotes). - - The script writes all six files atomically with consistent sorting, so the locale set stays in sync by construction. + - The script writes all seven files atomically with consistent sorting, so the locale set stays in sync by construction. - The script does not do the translation for you. You still must reason out each locale's copy and populate the script's `newKeys` object; the script only handles insertion, sorting, and writing. Do not skip the script just because the thinking happens regardless. ## Scope Checklist @@ -45,10 +45,10 @@ Do not skip this workflow because the fix is "just one key". ## Overview -- Locale files: `web/default/src/i18n/locales/{en,zh,fr,ja,ru,vi}.json` +- Locale files: `web/src/i18n/locales/{en,zh,zh-TW,fr,ja,ru,vi}.json` - Format: flat JSON under `"translation"` key, keys are English source strings - Base locale: `en.json` (most keys), fallback: `zh` (Chinese) -- Sync script: `bun run i18n:sync` (from `web/default/`) +- Sync script: `bun run i18n:sync` (from `web/`) - All `t()` calls must have corresponding keys in every locale file ## Small Fix Path @@ -56,7 +56,7 @@ Do not skip this workflow because the fix is "just one key". For a single known missing key (still script-only, no direct JSON edits): 1. Confirm the exact key at the call site and verify it is absent from all locale files. -2. Add the key via `add-missing-keys.mjs`, populating its `newKeys` object for every supported locale: `en`, `zh`, `fr`, `ja`, `ru`, `vi`. Even one key goes through the script; do not hand-edit the JSON. +2. Add the key via `add-missing-keys.mjs`, populating its `newKeys` object for every supported locale: `en`, `zh`, `zh-TW`, `fr`, `ja`, `ru`, `vi`. Even one key goes through the script; do not hand-edit the JSON. 3. The script preserves the flat `"translation"` object and keeps keys alphabetically sorted automatically. 4. Run a targeted search for the key in code and locale files. 5. Run `bun run i18n:sync` to normalize file order. This step is mandatory, not optional. @@ -66,14 +66,14 @@ For a single known missing key (still script-only, no direct JSON edits): ### Step 1: Run sync and read report ```bash -cd web/default && bun run i18n:sync +cd web && bun run i18n:sync ``` -Read `web/default/src/i18n/locales/_reports/_sync-report.json` to see per-locale status (missingCount, extrasCount, untranslatedCount). +Read `web/src/i18n/locales/_reports/_sync-report.json` to see per-locale status (missingCount, extrasCount, untranslatedCount). ### Step 2: Find missing keys (used in code but not in locale files) -Create and run `web/default/scripts/find-missing-keys.mjs`: +Create and run `web/scripts/find-missing-keys.mjs`: ```javascript import fs from 'node:fs/promises' @@ -136,7 +136,7 @@ if (missingKeys.size === 0) { ### Step 3: Find untranslated entries (value equals English) -Create and run `web/default/scripts/find-untranslated.mjs`: +Create and run `web/scripts/find-untranslated.mjs`: ```javascript import fs from 'node:fs/promises' @@ -167,7 +167,7 @@ const brandNames = new Set([ 'WeChat','Xinference','Xunfei','AI Proxy','One API', ]) -const locales = ['fr', 'ja', 'ru', 'zh', 'vi'] +const locales = ['fr', 'ja', 'ru', 'zh', 'zh-TW', 'vi'] for (const locale of locales) { const locFile = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, `${locale}.json`), 'utf8')) @@ -196,7 +196,7 @@ for (const locale of locales) { ### Step 4: Add translations -This script is the ONLY sanctioned way to write locale values. You MUST NOT bypass it by hand-filling the JSON files. Create `web/default/scripts/add-missing-keys.mjs` with this exact structure: +This script is the ONLY sanctioned way to write locale values. You MUST NOT bypass it by hand-filling the JSON files. Create `web/scripts/add-missing-keys.mjs` with this exact structure: ```javascript import fs from 'node:fs/promises' @@ -211,6 +211,7 @@ function stableStringify(obj) { const newKeys = { en: { /* "key": "English value" */ }, zh: { /* "key": "中文翻译" */ }, + 'zh-TW': { /* "key": "繁體中文翻譯" */ }, fr: { /* "key": "Traduction française" */ }, ja: { /* "key": "日本語翻訳" */ }, ru: { /* "key": "Русский перевод" */ }, @@ -257,7 +258,7 @@ Populate the `newKeys` object with actual translations for each locale. ### Step 5: Verify and clean up ```bash -cd web/default +cd web node scripts/add-missing-keys.mjs # apply translations node scripts/find-missing-keys.mjs # verify: should say "All t() keys found" bun run i18n:sync # normalize file order @@ -285,6 +286,7 @@ Delete temporary scripts after completion. |----------|------|-------| | English | en | Base locale, key = value | | Chinese | zh | Fallback locale, must be complete | +| Traditional Chinese | zh-TW | Use natural Traditional Chinese wording | | French | fr | Many English cognates are valid (e.g., "Configuration") | | Japanese | ja | Use katakana for technical loanwords | | Russian | ru | Use formal register | @@ -303,7 +305,7 @@ Delete temporary scripts after completion. ## Key Rules -1. All scripts run from `web/default/` directory +1. All scripts run from `web/` directory 2. Use `node scripts/xxx.mjs` (ESM format with top-level await) 3. Sort keys alphabetically when writing locale files 4. Always run `bun run i18n:sync` as the final step diff --git a/.agents/skills/shadcn-ui/SKILL.md b/.agents/skills/shadcn-ui/SKILL.md index c106690d531f..762282095877 100644 --- a/.agents/skills/shadcn-ui/SKILL.md +++ b/.agents/skills/shadcn-ui/SKILL.md @@ -3,7 +3,7 @@ name: shadcn-ui description: >- Give the assistant project-aware shadcn/ui context: components.json, composition patterns, CLI, registries, theming, and MCP. Use when working on - web/default UI, shadcn components, or presets. Overview aligns with + web UI, shadcn components, or presets. Overview aligns with https://ui.shadcn.com/docs/skills.md; full upstream skill text is vendored under vendor/shadcn/. --- @@ -37,7 +37,7 @@ npx skills add shadcn/ui That installs the skill where the `skills` CLI is available. **This repository** keeps the same intent under `.agents/skills/shadcn-ui/` (overview here + **vendored** upstream docs in [`vendor/shadcn/`](./vendor/shadcn/)) and runs the shadcn CLI from the frontend app root: ```bash -cd web/default && bunx shadcn@latest info --json +cd web && bunx shadcn@latest info --json ``` Learn more about skills at [skills.sh](https://skills.sh). @@ -48,7 +48,7 @@ Learn more about skills at [skills.sh](https://skills.sh). ### Project context -Run **`shadcn info --json`** (here: `cd web/default && bunx shadcn@latest info --json`) for framework, Tailwind version, aliases, base (`radix` | `base`), icon library, installed components, and resolved paths. +Run **`shadcn info --json`** (here: `cd web && bunx shadcn@latest info --json`) for framework, Tailwind version, aliases, base (`radix` | `base`), icon library, installed components, and resolved paths. ### CLI commands @@ -70,7 +70,7 @@ Vendored: [`vendor/shadcn/mcp.md`](./vendor/shadcn/mcp.md). Live docs: [MCP Serv ## How it works -1. **Project detection** — Applies when `components.json` exists (here: `web/default/components.json`). +1. **Project detection** — Applies when `components.json` exists (here: `web/components.json`). 2. **Context injection** — Use `shadcn info --json` as ground truth for imports and APIs. 3. **Pattern enforcement** — Use [`vendor/shadcn/rules/`](./vendor/shadcn/rules/) for concrete markup checks; the complete official workflow reference is listed below for deeper CLI, registry, and preset questions. 4. **Component discovery** — `shadcn docs`, `shadcn search`, MCP, or registries — see the official workflow reference and MCP doc when deeper context is needed. @@ -102,4 +102,4 @@ Snapshot from [shadcn-ui/ui `skills/shadcn`](https://github.com/shadcn-ui/ui/tre | Styling | [`vendor/shadcn/rules/styling.md`](./vendor/shadcn/rules/styling.md) | | Base vs Radix | [`vendor/shadcn/rules/base-vs-radix.md`](./vendor/shadcn/rules/base-vs-radix.md) | -**Workflow:** Prefer this **root** `SKILL.md` for repo paths (`web/default`, Bun). Read **`vendor/shadcn/official-shadcn-ui-workflow.md`** only when you need the complete official component, registry, or preset workflow. Use **`vendor/shadcn/rules/*.md`** when validating concrete markup. +**Workflow:** Prefer this **root** `SKILL.md` for repo paths (`web`, Bun). Read **`vendor/shadcn/official-shadcn-ui-workflow.md`** only when you need the complete official component, registry, or preset workflow. Use **`vendor/shadcn/rules/*.md`** when validating concrete markup. diff --git a/.dockerignore b/.dockerignore index 7a7193627a3b..0204d2e89809 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,8 +8,5 @@ docs .eslintcache .gocache /web/node_modules -/web/default/node_modules -/web/default/dist -/web/classic/node_modules -/web/classic/dist +/web/dist !THIRD-PARTY-LICENSES.md diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index edb1817aa332..ac4804a3b18d 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -34,7 +34,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Setup Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 @@ -47,7 +47,7 @@ jobs: NODE_OPTIONS: "--max-old-space-size=4096" run: | cd web - bun install + bun install --frozen-lockfile DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build cd .. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e519749794f..547f5ed47c3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,24 +29,14 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest - - name: Build Frontend (default) + - name: Build Frontend env: CI: "" run: | cd web bun install --frozen-lockfile - cd default DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build - cd ../.. - - name: Build Frontend (classic) - env: - CI: "" - run: | - cd web - bun install --filter ./classic --frozen-lockfile - cd classic - VITE_REACT_APP_VERSION=$VERSION bun run build - cd ../.. + cd .. - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -88,25 +78,15 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest - - name: Build Frontend (default) + - name: Build Frontend env: CI: "" NODE_OPTIONS: "--max-old-space-size=4096" run: | cd web bun install --frozen-lockfile - cd default DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build - cd ../.. - - name: Build Frontend (classic) - env: - CI: "" - run: | - cd web - bun install --filter ./classic --frozen-lockfile - cd classic - VITE_REACT_APP_VERSION=$VERSION bun run build - cd ../.. + cd .. - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -146,24 +126,14 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest - - name: Build Frontend (default) + - name: Build Frontend env: CI: "" run: | cd web bun install --frozen-lockfile - cd default DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build - cd ../.. - - name: Build Frontend (classic) - env: - CI: "" - run: | - cd web - bun install --filter ./classic --frozen-lockfile - cd classic - VITE_REACT_APP_VERSION=$VERSION bun run build - cd ../.. + cd .. - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: diff --git a/.gitignore b/.gitignore index c3afceb021f1..25f8469ee518 100644 --- a/.gitignore +++ b/.gitignore @@ -8,10 +8,8 @@ upload build *.db-journal logs -web/default/dist -web/classic/dist -web/node_modules web/dist +web/node_modules .env one-api new-api @@ -28,7 +26,7 @@ plans electron/node_modules electron/dist -data/ +/data/ .gomodcache/ .gocache-temp .gopath diff --git a/AGENTS.md b/AGENTS.md index 8f41dcf72c86..8f6e6daca3a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,10 +35,8 @@ types/ — Type definitions (relay formats, file sources, errors) i18n/ — Backend internationalization (go-i18n, en/zh) oauth/ — OAuth provider implementations pkg/ — Internal packages (cachex, ionet) -web/ — Frontend themes container - web/default/ — Default frontend (React 19, Rsbuild, Base UI, Tailwind) - web/classic/ — Classic frontend (React 18, Vite, Semi Design) - web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi) +web/ — Frontend (React 19, Rsbuild, Base UI, Tailwind) + src/i18n/ — Frontend internationalization (i18next, en/zh/zh-TW/fr/ru/ja/vi) ``` ## Internationalization (i18n) @@ -47,12 +45,12 @@ web/ — Frontend themes container - Library: `nicksnyder/go-i18n/v2` - Languages: en, zh -### Frontend (`web/default/src/i18n/`) +### Frontend (`web/src/i18n/`) - Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector` -- Languages: en (base), zh (fallback), fr, ru, ja, vi -- Translation files: `web/default/src/i18n/locales/{lang}.json` — flat JSON, keys are English source strings +- Languages: en (base), zh (fallback), zh-TW, fr, ru, ja, vi +- Translation files: `web/src/i18n/locales/{lang}.json` — flat JSON, keys are English source strings - Usage: `useTranslation()` hook, call `t('English key')` in components -- CLI tools: `bun run i18n:sync` (from `web/default/`) +- CLI tools: `bun run i18n:sync` (from `web/`) ## Rules @@ -126,14 +124,14 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag ### Frontend Rules -- Use `bun` as the preferred package manager and script runner for the frontend (`web/default/`): +- Use `bun` as the preferred package manager and script runner for the frontend (`web/`): - `bun install` for dependency installation - `bun run dev` for development server - `bun run build` for production build - `bun run i18n:*` for i18n tooling -- Frontend UI text must support i18n with `i18next`/`react-i18next`. Use flat JSON locale files in `web/default/src/i18n/locales/{lang}.json`, with English source strings as keys. +- Frontend UI text must support i18n with `i18next`/`react-i18next`. Use flat JSON locale files in `web/src/i18n/locales/{lang}.json`, with English source strings as keys. - In React components, use `useTranslation()` and call `t('English key')` for user-facing text. -- Follow `web/default/AGENTS.md` for detailed frontend conventions, including TypeScript, component structure, styling, accessibility, testing, and build checks. +- Follow `web/AGENTS.md` for detailed frontend conventions, including TypeScript, component structure, styling, accessibility, testing, and build checks. ### Project Governance diff --git a/Dockerfile b/Dockerfile index e2788f55b2bd..10345a5c8514 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,23 +2,10 @@ FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2 WORKDIR /build/web COPY web/package.json web/bun.lock ./ -COPY web/default/package.json ./default/package.json -COPY web/classic/package.json ./classic/package.json RUN bun install --frozen-lockfile -COPY ./web/default ./default +COPY ./web ./ COPY ./VERSION /build/VERSION -RUN cd default && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build - -FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic - -WORKDIR /build/web -COPY web/package.json web/bun.lock ./ -COPY web/default/package.json ./default/package.json -COPY web/classic/package.json ./classic/package.json -RUN bun install --filter ./classic --frozen-lockfile -COPY ./web/classic ./classic -COPY ./VERSION /build/VERSION -RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build +RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2 ENV GO111MODULE=on CGO_ENABLED=0 @@ -34,8 +21,7 @@ ADD go.mod go.sum ./ RUN go mod download COPY . . -COPY --from=builder /build/web/default/dist ./web/default/dist -COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist +COPY --from=builder /build/web/dist ./web/dist RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a diff --git a/Dockerfile.dev b/Dockerfile.dev index 81c221bf113c..bdc5be42da77 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,5 +1,5 @@ # Backend-only build for frontend development -# Skips frontend build, uses a placeholder for //go:embed web/dist +# Skips frontend build and uses a placeholder for //go:embed web/dist FROM golang:1.26.1-alpine AS builder @@ -16,9 +16,8 @@ RUN go mod download COPY . . -RUN mkdir -p web/default/dist web/classic/dist && \ - echo 'devuse frontend dev server' > web/default/dist/index.html && \ - echo 'devuse frontend dev server' > web/classic/dist/index.html +RUN mkdir -p web/dist && \ + echo 'devuse frontend dev server' > web/dist/index.html RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api diff --git a/README.en.md b/README.en.md index e8a6a60565fb..68b1c18cf2f8 100644 --- a/README.en.md +++ b/README.en.md @@ -1,6 +1,6 @@
-![new-api](/web/default/public/logo.png) +![new-api](/web/public/logo.png) # New API diff --git a/README.fr.md b/README.fr.md index 38506ab693c7..aa45a9ce88b3 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,6 +1,6 @@
-![new-api](/web/default/public/logo.png) +![new-api](/web/public/logo.png) # New API diff --git a/README.ja.md b/README.ja.md index e02ff4d6f51f..bd3c0f2c5c8c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,6 +1,6 @@
-![new-api](/web/default/public/logo.png) +![new-api](/web/public/logo.png) # New API diff --git a/README.md b/README.md index 118f7ea52fd6..6730cfae7c15 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-![new-api](/web/default/public/logo.png) +![new-api](/web/public/logo.png) # New API diff --git a/README.zh_CN.md b/README.zh_CN.md index fddef3d35001..eb7cadd927b6 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -1,6 +1,6 @@
-![new-api](/web/default/public/logo.png) +![new-api](/web/public/logo.png) # New API diff --git a/README.zh_TW.md b/README.zh_TW.md index 4363cc06fb12..aaa4d0f15500 100644 --- a/README.zh_TW.md +++ b/README.zh_TW.md @@ -1,6 +1,6 @@
-![new-api](/web/default/public/logo.png) +![new-api](/web/public/logo.png) # New API diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md index e95e5542423f..e04a8cd7028b 100644 --- a/THIRD-PARTY-LICENSES.md +++ b/THIRD-PARTY-LICENSES.md @@ -3,7 +3,7 @@ This file summarizes direct third-party dependencies used by distributed builds of this project. It is an engineering compliance artifact and should be kept with Docker images, standalone binaries, frontend bundles, and Electron installers. -Scope: direct dependencies from `go.mod`, `web/default/package.json`, `web/classic/package.json`, and `electron/package.json`. +Scope: direct dependencies from `go.mod`, `web/package.json`, and `electron/package.json`. Transitive dependencies should be audited before a final external release. ## Dependency Inventory @@ -65,132 +65,79 @@ Transitive dependencies should be audited before a final external release. | backend | production | Go | `gorm.io/driver/postgres` | `v1.5.2` | MIT | | backend | production | Go | `gorm.io/gorm` | `v1.25.2` | MIT | | backend | production | Go | `github.com/expr-lang/expr` | `v1.17.8` | MIT | -| web/default | production | npm | `@base-ui/react` | `1.4.1` | MIT | -| web/default | production | npm | `@fontsource-variable/public-sans` | `5.2.7` | OFL-1.1 | -| web/default | production | npm | `@hookform/resolvers` | `5.2.2` | MIT | -| web/default | production | npm | `@hugeicons/core-free-icons` | `4.1.1` | MIT | -| web/default | production | npm | `@hugeicons/react` | `1.1.6` | MIT | -| web/default | production | npm | `@lobehub/icons` | `4.12.0` | MIT | -| web/default | production | npm | `@tailwindcss/postcss` | `4.2.2` | MIT | -| web/default | production | npm | `@tanstack/react-query` | `5.97.0` | MIT | -| web/default | production | npm | `@tanstack/react-router` | `1.168.23` | MIT | -| web/default | production | npm | `@tanstack/react-table` | `8.21.3` | MIT | -| web/default | production | npm | `@tanstack/react-virtual` | `3.13.23` | MIT | -| web/default | production | npm | `@visactor/react-vchart` | `2.0.21` | MIT | -| web/default | production | npm | `@visactor/vchart` | `2.0.21` | MIT | -| web/default | production | npm | `ai` | `6.0.158` | Apache-2.0 | -| web/default | production | npm | `auto-skeleton-react` | `1.0.5` | MIT | -| web/default | production | npm | `axios` | `1.15.0` | MIT | -| web/default | production | npm | `class-variance-authority` | `0.7.1` | Apache-2.0 | -| web/default | production | npm | `clsx` | `2.1.1` | MIT | -| web/default | production | npm | `cmdk` | `1.1.1` | MIT | -| web/default | production | npm | `date-fns` | `4.1.0` | MIT | -| web/default | production | npm | `dayjs` | `1.11.20` | MIT | -| web/default | production | npm | `i18next` | `25.10.10` | MIT | -| web/default | production | npm | `i18next-browser-languagedetector` | `8.2.1` | MIT | -| web/default | production | npm | `input-otp` | `1.4.2` | MIT | -| web/default | production | npm | `lucide-react` | `1.8.0` | ISC | -| web/default | production | npm | `motion` | `12.38.0` | MIT | -| web/default | production | npm | `nanoid` | `5.1.7` | MIT | -| web/default | production | npm | `next-themes` | `0.4.6` | MIT | -| web/default | production | npm | `qrcode.react` | `4.2.0` | ISC | -| web/default | production | npm | `react` | `19.2.5` | MIT | -| web/default | production | npm | `react-day-picker` | `9.14.0` | MIT | -| web/default | production | npm | `react-dom` | `19.2.5` | MIT | -| web/default | production | npm | `react-hook-form` | `7.72.1` | MIT | -| web/default | production | npm | `react-i18next` | `16.6.6` | MIT | -| web/default | production | npm | `react-icons` | `5.6.0` | MIT | -| web/default | production | npm | `react-markdown` | `10.1.0` | MIT | -| web/default | production | npm | `react-resizable-panels` | `4.11.0` | MIT | -| web/default | production | npm | `react-top-loading-bar` | `3.0.2` | MIT | -| web/default | production | npm | `recharts` | `3.8.0` | MIT | -| web/default | production | npm | `rehype-raw` | `7.0.0` | MIT | -| web/default | production | npm | `remark-gfm` | `4.0.1` | MIT | -| web/default | production | npm | `shiki` | `4.0.2` | MIT | -| web/default | production | npm | `sonner` | `2.0.7` | MIT | -| web/default | production | npm | `sse.js` | `2.8.0` | Apache-2.0 | -| web/default | production | npm | `streamdown` | `2.5.0` | Apache-2.0 | -| web/default | production | npm | `tailwind-merge` | `3.5.0` | MIT | -| web/default | production | npm | `tailwindcss` | `4.2.2` | MIT | -| web/default | production | npm | `tokenlens` | `1.3.1` | MIT | -| web/default | production | npm | `tw-animate-css` | `1.4.0` | MIT | -| web/default | production | npm | `use-stick-to-bottom` | `1.1.3` | MIT | -| web/default | production | npm | `vaul` | `1.1.2` | MIT | -| web/default | production | npm | `zod` | `4.3.6` | MIT | -| web/default | production | npm | `zustand` | `5.0.12` | MIT | -| web/default | development | npm | `@eslint/js` | `10.0.1` | MIT | -| web/default | development | npm | `@rsbuild/core` | `2.0.1` | MIT | -| web/default | development | npm | `@rsbuild/plugin-react` | `2.0.0` | MIT | -| web/default | development | npm | `@tanstack/eslint-plugin-query` | `5.97.0` | MIT | -| web/default | development | npm | `@tanstack/react-query-devtools` | `5.97.0` | MIT | -| web/default | development | npm | `@tanstack/react-router-devtools` | `1.166.13` | MIT | -| web/default | development | npm | `@tanstack/router-plugin` | `1.167.23` | MIT | -| web/default | development | npm | `@trivago/prettier-plugin-sort-imports` | `6.0.2` | Apache-2.0 | -| web/default | development | npm | `@types/node` | `25.6.0` | MIT | -| web/default | development | npm | `@types/react` | `19.2.14` | MIT | -| web/default | development | npm | `@types/react-dom` | `19.2.3` | MIT | -| web/default | development | npm | `@xyflow/react` | `12.10.2` | MIT | -| web/default | development | npm | `embla-carousel-react` | `8.6.0` | MIT | -| web/default | development | npm | `eslint` | `10.2.0` | MIT | -| web/default | development | npm | `eslint-plugin-react-hooks` | `7.0.1` | MIT | -| web/default | development | npm | `eslint-plugin-react-refresh` | `0.5.2` | MIT | -| web/default | development | npm | `globals` | `17.4.0` | MIT | -| web/default | development | npm | `knip` | `6.3.1` | ISC | -| web/default | development | npm | `prettier` | `3.8.2` | MIT | -| web/default | development | npm | `prettier-plugin-tailwindcss` | `0.7.2` | MIT | -| web/default | development | npm | `shadcn` | `3.8.5` | MIT | -| web/default | development | npm | `typescript` | `5.9.3` | Apache-2.0 | -| web/default | development | npm | `typescript-eslint` | `8.58.1` | MIT | -| web/classic | production | npm | `@douyinfe/semi-icons` | `2.72.2` | MIT | -| web/classic | production | npm | `@douyinfe/semi-ui` | `2.72.2` | MIT | -| web/classic | production | npm | `@lobehub/icons` | `2.1.0` | MIT | -| web/classic | production | npm | `@visactor/react-vchart` | `1.8.11` | MIT | -| web/classic | production | npm | `@visactor/vchart` | `1.8.11` | MIT | -| web/classic | production | npm | `@visactor/vchart-semi-theme` | `1.8.8` | MIT | -| web/classic | production | npm | `axios` | `1.15.0` | MIT | -| web/classic | production | npm | `clsx` | `2.1.1` | MIT | -| web/classic | production | npm | `dayjs` | `1.11.13` | MIT | -| web/classic | production | npm | `history` | `5.3.0` | MIT | -| web/classic | production | npm | `i18next` | `23.16.8` | MIT | -| web/classic | production | npm | `i18next-browser-languagedetector` | `7.2.2` | MIT | -| web/classic | production | npm | `katex` | `0.16.22` | MIT | -| web/classic | production | npm | `lucide-react` | `0.511.0` | ISC | -| web/classic | production | npm | `marked` | `4.3.0` | MIT | -| web/classic | production | npm | `mermaid` | `11.6.0` | MIT | -| web/classic | production | npm | `qrcode.react` | `4.2.0` | ISC | -| web/classic | production | npm | `react` | `18.3.1` | MIT | -| web/classic | production | npm | `react-dom` | `18.3.1` | MIT | -| web/classic | production | npm | `react-dropzone` | `14.3.5` | MIT | -| web/classic | production | npm | `react-fireworks` | `1.0.4` | ISC | -| web/classic | production | npm | `react-i18next` | `13.5.0` | MIT | -| web/classic | production | npm | `react-icons` | `5.5.0` | MIT | -| web/classic | production | npm | `react-markdown` | `10.1.0` | MIT | -| web/classic | production | npm | `react-router-dom` | `6.28.1` | MIT | -| web/classic | production | npm | `react-telegram-login` | `1.1.2` | MIT | -| web/classic | production | npm | `react-toastify` | `9.1.3` | MIT | -| web/classic | production | npm | `react-turnstile` | `1.1.4` | MIT | -| web/classic | production | npm | `rehype-highlight` | `7.0.2` | MIT | -| web/classic | production | npm | `rehype-katex` | `7.0.1` | MIT | -| web/classic | production | npm | `remark-breaks` | `4.0.0` | MIT | -| web/classic | production | npm | `remark-gfm` | `4.0.1` | MIT | -| web/classic | production | npm | `remark-math` | `6.0.0` | MIT | -| web/classic | production | npm | `sse.js` | `2.6.0` | Apache-2.0 | -| web/classic | production | npm | `unist-util-visit` | `5.0.0` | MIT | -| web/classic | production | npm | `use-debounce` | `10.0.4` | MIT | -| web/classic | development | npm | `@douyinfe/vite-plugin-semi` | `2.74.0-alpha.6` | MIT | -| web/classic | development | npm | `@so1ve/prettier-config` | `3.1.0` | MIT | -| web/classic | development | npm | `@vitejs/plugin-react` | `4.3.4` | MIT | -| web/classic | development | npm | `autoprefixer` | `10.4.21` | MIT | -| web/classic | development | npm | `code-inspector-plugin` | `1.3.3` | MIT | -| web/classic | development | npm | `eslint` | `8.57.0` | MIT | -| web/classic | development | npm | `eslint-plugin-header` | `3.1.1` | MIT | -| web/classic | development | npm | `eslint-plugin-react-hooks` | `5.2.0` | MIT | -| web/classic | development | npm | `i18next-cli` | `1.15.0` | MIT | -| web/classic | development | npm | `postcss` | `8.5.3` | MIT | -| web/classic | development | npm | `prettier` | `3.4.2` | MIT | -| web/classic | development | npm | `tailwindcss` | `3.4.17` | MIT | -| web/classic | development | npm | `typescript` | `4.4.2` | Apache-2.0 | -| web/classic | development | npm | `vite` | `5.4.11` | MIT | +| web | production | npm | `@base-ui/react` | `1.6.0` | MIT | +| web | production | npm | `@codemirror/lang-markdown` | `6.5.1` | MIT | +| web | production | npm | `@codemirror/language` | `6.12.4` | MIT | +| web | production | npm | `@codemirror/state` | `6.7.1` | MIT | +| web | production | npm | `@codemirror/view` | `6.43.6` | MIT | +| web | production | npm | `@fontsource-variable/lora` | `5.3.0` | OFL-1.1 | +| web | production | npm | `@fontsource-variable/public-sans` | `5.3.0` | OFL-1.1 | +| web | production | npm | `@hookform/resolvers` | `5.4.0` | MIT | +| web | production | npm | `@hugeicons/core-free-icons` | `4.2.2` | MIT | +| web | production | npm | `@hugeicons/react` | `1.1.9` | MIT | +| web | production | npm | `@lezer/highlight` | `1.2.3` | MIT | +| web | production | npm | `@lobehub/icons` | `5.14.0` | MIT | +| web | production | npm | `@tanstack/react-query` | `5.101.2` | MIT | +| web | production | npm | `@tanstack/react-router` | `1.170.18` | MIT | +| web | production | npm | `@tanstack/react-table` | `8.21.3` | MIT | +| web | production | npm | `@tanstack/react-virtual` | `3.14.6` | MIT | +| web | production | npm | `@visactor/react-vchart` | `2.1.4` | MIT | +| web | production | npm | `@visactor/vchart` | `2.1.4` | MIT | +| web | production | npm | `ai` | `7.0.31` | Apache-2.0 | +| web | production | npm | `auto-skeleton-react` | `1.0.5` | MIT | +| web | production | npm | `axios` | `1.18.1` | MIT | +| web | production | npm | `class-variance-authority` | `0.7.1` | Apache-2.0 | +| web | production | npm | `clsx` | `2.1.1` | MIT | +| web | production | npm | `cmdk` | `1.1.1` | MIT | +| web | production | npm | `dayjs` | `1.11.21` | MIT | +| web | production | npm | `dompurify` | `3.4.11` | Apache-2.0 OR MPL-2.0 | +| web | production | npm | `i18next` | `26.3.6` | MIT | +| web | production | npm | `i18next-browser-languagedetector` | `8.2.1` | MIT | +| web | production | npm | `input-otp` | `1.4.2` | MIT | +| web | production | npm | `katex` | `0.17.0` | MIT | +| web | production | npm | `lucide-react` | `1.25.0` | ISC | +| web | production | npm | `marked` | `18.0.6` | MIT | +| web | production | npm | `motion` | `12.42.2` | MIT | +| web | production | npm | `nanoid` | `5.1.16` | MIT | +| web | production | npm | `next-themes` | `0.4.6` | MIT | +| web | production | npm | `qrcode.react` | `4.2.0` | ISC | +| web | production | npm | `react` | `19.2.7` | MIT | +| web | production | npm | `react-day-picker` | `10.0.1` | MIT | +| web | production | npm | `react-dom` | `19.2.7` | MIT | +| web | production | npm | `react-hook-form` | `7.82.0` | MIT | +| web | production | npm | `react-i18next` | `17.0.10` | MIT | +| web | production | npm | `react-icons` | `5.7.0` | MIT | +| web | production | npm | `react-resizable-panels` | `4.12.2` | MIT | +| web | production | npm | `react-top-loading-bar` | `3.0.2` | MIT | +| web | production | npm | `recharts` | `3.9.1` | MIT | +| web | production | npm | `shiki` | `4.3.1` | MIT | +| web | production | npm | `sonner` | `2.0.7` | MIT | +| web | production | npm | `sse.js` | `2.8.0` | Apache-2.0 | +| web | production | npm | `stream-markdown-parser` | `1.1.3` | MIT | +| web | production | npm | `tailwind-merge` | `3.6.0` | MIT | +| web | production | npm | `tailwindcss` | `4.3.3` | MIT | +| web | production | npm | `tokenlens` | `1.3.1` | MIT | +| web | production | npm | `tw-animate-css` | `1.4.0` | MIT | +| web | production | npm | `use-stick-to-bottom` | `1.1.6` | MIT | +| web | production | npm | `vaul` | `1.1.2` | MIT | +| web | production | npm | `zod` | `4.4.3` | MIT | +| web | production | npm | `zustand` | `5.0.14` | MIT | +| web | development | npm | `@rsbuild/core` | `2.1.6` | MIT | +| web | development | npm | `@rsbuild/plugin-react` | `2.1.0` | MIT | +| web | development | npm | `@rsbuild/plugin-tailwindcss` | `2.0.3` | MIT | +| web | development | npm | `@tanstack/react-query-devtools` | `5.101.2` | MIT | +| web | development | npm | `@tanstack/react-router-devtools` | `1.167.0` | MIT | +| web | development | npm | `@tanstack/router-plugin` | `1.168.23` | MIT | +| web | development | npm | `@types/node` | `26.1.1` | MIT | +| web | development | npm | `@types/react` | `19.2.17` | MIT | +| web | development | npm | `@types/react-dom` | `19.2.3` | MIT | +| web | development | npm | `@typescript/native-preview` | `7.0.0-dev.20260707.2` | Apache-2.0 | +| web | development | npm | `@xyflow/react` | `12.11.2` | MIT | +| web | development | npm | `embla-carousel-react` | `8.6.0` | MIT | +| web | development | npm | `knip` | `6.27.0` | ISC | +| web | development | npm | `oxfmt` | `0.57.0` | MIT | +| web | development | npm | `oxlint` | `1.74.0` | MIT | +| web | development | npm | `shadcn` | `4.13.1` | MIT | | electron | development | npm | `cross-env` | `7.0.3` | MIT | | electron | development | npm | `electron` | `39.8.5` | MIT | | electron | development | npm | `electron-builder` | `26.7.0` | MIT | diff --git a/common/constants.go b/common/constants.go index 45699d0e1f01..d6b4fb52284c 100644 --- a/common/constants.go +++ b/common/constants.go @@ -4,9 +4,7 @@ import ( "crypto/tls" //"os" //"strconv" - "strings" "sync" - "sync/atomic" "time" "github.com/google/uuid" @@ -19,44 +17,6 @@ var Footer = "" var Logo = "" var TopUpLink = "" -var themeValue atomic.Value // stores string; safe for concurrent read/write - -func init() { - themeValue.Store("classic") -} - -func GetTheme() string { - return themeValue.Load().(string) -} - -// SetTheme updates the frontend theme atomically. -// Only "default" and "classic" are accepted; other values are silently ignored. -func SetTheme(t string) { - if t == "default" || t == "classic" { - themeValue.Store(t) - } -} - -// ThemeAwarePath rewrites legacy /console/* paths to the default-theme -// equivalents when the active theme is "default". For "classic" (or any -// other theme) the path is returned unchanged. The function only touches -// known prefixes so it is safe to call with arbitrary suffixes and query -// strings. -func ThemeAwarePath(suffix string) string { - if GetTheme() != "default" { - return suffix - } - switch { - case strings.HasPrefix(suffix, "/console/topup"): - return strings.Replace(suffix, "/console/topup", "/wallet", 1) - case strings.HasPrefix(suffix, "/console/log"): - return strings.Replace(suffix, "/console/log", "/usage-logs", 1) - case strings.HasPrefix(suffix, "/console/personal"): - return strings.Replace(suffix, "/console/personal", "/profile", 1) - } - return suffix -} - // var ChatLink = "" // var ChatLink2 = "" var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens diff --git a/common/embed-file-system.go b/common/embed-file-system.go index e76fbb8016f4..8de8699506a0 100644 --- a/common/embed-file-system.go +++ b/common/embed-file-system.go @@ -41,29 +41,3 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem { FileSystem: http.FS(efs), } } - -// themeAwareFileSystem delegates to the appropriate embedded FS based on -// the current theme (via GetTheme). This enables runtime theme switching -// without restarting the server. -type themeAwareFileSystem struct { - defaultFS static.ServeFileSystem - classicFS static.ServeFileSystem -} - -func (t *themeAwareFileSystem) Exists(prefix string, path string) bool { - if GetTheme() == "classic" { - return t.classicFS.Exists(prefix, path) - } - return t.defaultFS.Exists(prefix, path) -} - -func (t *themeAwareFileSystem) Open(name string) (http.File, error) { - if GetTheme() == "classic" { - return t.classicFS.Open(name) - } - return t.defaultFS.Open(name) -} - -func NewThemeAwareFS(defaultFS, classicFS static.ServeFileSystem) static.ServeFileSystem { - return &themeAwareFileSystem{defaultFS: defaultFS, classicFS: classicFS} -} diff --git a/controller/audit.go b/controller/audit.go index 36080724f8f4..d6974b900806 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -12,7 +12,7 @@ import ( ) // auditContentTemplates 将稳定的操作标识 action 映射为英文兜底模板,渲染后写入 -// Log.Content(供导出 / 经典前端等非本地化消费者使用)。占位符为 ${name},由该 +// Log.Content(供导出等非本地化消费者使用)。占位符为 ${name},由该 // action 的 params 填充。本地化展示文案在前端 i18n 模板中维护,本表是语言中立的 // 英文基线——调用方因此无需在每个埋点处手写句子(避免与 params 重复书写同一份值)。 var auditContentTemplates = map[string]string{ diff --git a/controller/console_migrate.go b/controller/console_migrate.go deleted file mode 100644 index 4584961047cf..000000000000 --- a/controller/console_migrate.go +++ /dev/null @@ -1,106 +0,0 @@ -// 用于迁移检测的旧键,该文件下个版本会删除 - -package controller - -import ( - "encoding/json" - "net/http" - - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/model" - - "github.com/gin-gonic/gin" -) - -// MigrateConsoleSetting 迁移旧的控制台相关配置到 console_setting.* -func MigrateConsoleSetting(c *gin.Context) { - // 读取全部 option - opts, err := model.AllOption() - if err != nil { - common.SysError("failed to get all options: " + err.Error()) - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "获取配置失败,请稍后重试"}) - return - } - // 建立 map - valMap := map[string]string{} - for _, o := range opts { - valMap[o.Key] = o.Value - } - - // 处理 APIInfo - if v := valMap["ApiInfo"]; v != "" { - var arr []map[string]interface{} - if err := json.Unmarshal([]byte(v), &arr); err == nil { - if len(arr) > 50 { - arr = arr[:50] - } - bytes, _ := json.Marshal(arr) - model.UpdateOption("console_setting.api_info", string(bytes)) - } - model.UpdateOption("ApiInfo", "") - } - // Announcements 直接搬 - if v := valMap["Announcements"]; v != "" { - model.UpdateOption("console_setting.announcements", v) - model.UpdateOption("Announcements", "") - } - // FAQ 转换 - if v := valMap["FAQ"]; v != "" { - var arr []map[string]interface{} - if err := json.Unmarshal([]byte(v), &arr); err == nil { - out := []map[string]interface{}{} - for _, item := range arr { - q, _ := item["question"].(string) - if q == "" { - q, _ = item["title"].(string) - } - a, _ := item["answer"].(string) - if a == "" { - a, _ = item["content"].(string) - } - if q != "" && a != "" { - out = append(out, map[string]interface{}{"question": q, "answer": a}) - } - } - if len(out) > 50 { - out = out[:50] - } - bytes, _ := json.Marshal(out) - model.UpdateOption("console_setting.faq", string(bytes)) - } - model.UpdateOption("FAQ", "") - } - // Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups) - url := valMap["UptimeKumaUrl"] - slug := valMap["UptimeKumaSlug"] - if url != "" && slug != "" { - // 仅当同时存在 URL 与 Slug 时才进行迁移 - groups := []map[string]interface{}{ - { - "id": 1, - "categoryName": "old", - "url": url, - "slug": slug, - "description": "", - }, - } - bytes, _ := json.Marshal(groups) - model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes)) - } - // 清空旧键内容 - if url != "" { - model.UpdateOption("UptimeKumaUrl", "") - } - if slug != "" { - model.UpdateOption("UptimeKumaSlug", "") - } - - // 删除旧键记录 - oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"} - model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{}) - - // 重新加载 OptionMap - model.InitOptionMap() - common.SysLog("console setting migrated") - c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"}) -} diff --git a/controller/log.go b/controller/log.go index ce9b4666fa5e..470c759fc1a1 100644 --- a/controller/log.go +++ b/controller/log.go @@ -149,29 +149,3 @@ func GetLogsSelfStat(c *gin.Context) { }) return } - -// DeleteHistoryLogs is the legacy synchronous log cleanup endpoint (DELETE /api/log/). -// It deletes directly instead of going through the async system task. It is kept only -// for the classic frontend; the default frontend uses POST /api/system-task/log-cleanup. -// TODO: remove this handler (and its route) once the classic frontend is removed. -func DeleteHistoryLogs(c *gin.Context) { - targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64) - if targetTimestamp == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "target timestamp is required", - }) - return - } - count, err := model.DeleteOldLog(c.Request.Context(), targetTimestamp, 100) - if err != nil { - common.ApiError(c, err) - return - } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": count, - }) - return -} diff --git a/controller/misc.go b/controller/misc.go index fb2029878747..1a48399bcc97 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -63,7 +63,7 @@ func GetStatus(c *gin.Context) { "linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel, "telegram_oauth": common.TelegramOAuthEnabled, "telegram_bot_name": common.TelegramBotName, - "theme": system_setting.GetThemeSettings().Frontend, + "theme": "default", "system_name": common.SystemName, "logo": common.Logo, "footer_html": common.Footer, diff --git a/controller/option.go b/controller/option.go index a97f07b841b7..6a91086a6c3d 100644 --- a/controller/option.go +++ b/controller/option.go @@ -80,6 +80,9 @@ func GetOptions(c *gin.Context) { optionValues := make(map[string]string) common.OptionMapRWMutex.Lock() for k, v := range common.OptionMap { + if k == "theme.frontend" { + continue + } value := common.Interface2String(v) isSensitiveKey := strings.HasSuffix(k, "Token") || strings.HasSuffix(k, "Secret") || @@ -216,10 +219,10 @@ func UpdateOption(c *gin.Context) { return } case "theme.frontend": - if option.Value != "default" && option.Value != "classic" { + if option.Value != "default" { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "无效的主题值,可选值:default(新版前端)、classic(经典前端)", + "message": "Classic 前端已移除,主题只能设置为 default", }) return } diff --git a/controller/return_path.go b/controller/return_path.go index 28378b7e5fe3..55c348e2611f 100644 --- a/controller/return_path.go +++ b/controller/return_path.go @@ -3,11 +3,10 @@ package controller import ( "strings" - "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/setting/system_setting" ) func paymentReturnPath(suffix string) string { base := strings.TrimRight(system_setting.ServerAddress, "/") - return base + common.ThemeAwarePath(suffix) + return base + suffix } diff --git a/controller/return_path_test.go b/controller/return_path_test.go new file mode 100644 index 000000000000..f4f499b32211 --- /dev/null +++ b/controller/return_path_test.go @@ -0,0 +1,25 @@ +package controller + +import ( + "testing" + + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/stretchr/testify/assert" +) + +func TestPaymentReturnPathUsesDefaultDashboardRoutes(t *testing.T) { + previousAddress := system_setting.ServerAddress + system_setting.ServerAddress = "https://dashboard.example.com/" + t.Cleanup(func() { system_setting.ServerAddress = previousAddress }) + + assert.Equal( + t, + "https://dashboard.example.com/wallet?pay=success", + paymentReturnPath("/wallet?pay=success"), + ) + assert.Equal( + t, + "https://dashboard.example.com/usage-logs", + paymentReturnPath("/usage-logs"), + ) +} diff --git a/controller/subscription_payment_epay.go b/controller/subscription_payment_epay.go index 7dece6badce2..139b7b810167 100644 --- a/controller/subscription_payment_epay.go +++ b/controller/subscription_payment_epay.go @@ -176,7 +176,7 @@ func SubscriptionEpayReturn(c *gin.Context) { if c.Request.Method == "POST" { // POST 请求:从 POST body 解析参数 if err := c.Request.ParseForm(); err != nil { - c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail")) + c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail")) return } params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string { @@ -192,29 +192,29 @@ func SubscriptionEpayReturn(c *gin.Context) { } if len(params) == 0 { - c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail")) + c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail")) return } client := GetEpayClient() if client == nil { - c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail")) + c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail")) return } verifyInfo, err := client.Verify(params) if err != nil || !verifyInfo.VerifyStatus { - c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail")) + c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail")) return } if verifyInfo.TradeStatus == epay.StatusTradeSuccess { LockOrder(verifyInfo.ServiceTradeNo) defer UnlockOrder(verifyInfo.ServiceTradeNo) if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), model.PaymentProviderEpay, verifyInfo.Type); err != nil { - c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail")) + c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail")) return } - c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=success")) + c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=success")) return } - c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=pending")) + c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=pending")) } diff --git a/controller/subscription_payment_stripe.go b/controller/subscription_payment_stripe.go index f6e95867ca4f..07121e973b2b 100644 --- a/controller/subscription_payment_stripe.go +++ b/controller/subscription_payment_stripe.go @@ -114,8 +114,8 @@ func genStripeSubscriptionLink(referenceId string, customerId string, email stri params := &stripe.CheckoutSessionParams{ ClientReferenceID: stripe.String(referenceId), - SuccessURL: stripe.String(paymentReturnPath("/console/topup")), - CancelURL: stripe.String(paymentReturnPath("/console/topup")), + SuccessURL: stripe.String(paymentReturnPath("/wallet")), + CancelURL: stripe.String(paymentReturnPath("/wallet")), LineItems: []*stripe.CheckoutSessionLineItemParams{ { Price: stripe.String(priceId), diff --git a/controller/telegram.go b/controller/telegram.go index 104f7a8151c8..e738307ff8cf 100644 --- a/controller/telegram.go +++ b/controller/telegram.go @@ -83,47 +83,37 @@ func TelegramBindStart(c *gin.Context) { func TelegramBind(c *gin.Context) { if !common.TelegramOAuthEnabled { - telegramBindFailure( - c, - http.StatusOK, - "管理员未开启通过 Telegram 登录以及注册", - telegramBindErrorDisabled, - ) + telegramBindFailure(c, telegramBindErrorDisabled) return } params := c.Request.URL.Query() telegramId, err := verifyTelegramAuthorization(params, common.TelegramBotToken, time.Now()) if err != nil { common.SysLog("TelegramBind authorization failed: " + err.Error()) - telegramBindFailure(c, http.StatusOK, "无效的请求", telegramBindErrorInvalidRequest) + telegramBindFailure(c, telegramBindErrorInvalidRequest) return } pendingFlow, err := model.GetAuthFlow(c.Param("flow_token"), model.AuthFlowMatch{ Purpose: model.AuthFlowPurposeTelegramBind, }) if err != nil { - if common.GetTheme() == "default" && - !errors.Is(err, model.ErrAuthFlowInvalid) && + if !errors.Is(err, model.ErrAuthFlowInvalid) && !errors.Is(err, model.ErrAuthFlowExpired) && !errors.Is(err, model.ErrAuthFlowConsumed) { common.SysError("TelegramBind flow lookup failed: " + err.Error()) - telegramBindFailure(c, http.StatusOK, "", telegramBindErrorInternal) + telegramBindFailure(c, telegramBindErrorInternal) return } - telegramBindFailure(c, http.StatusForbidden, "绑定流程已过期或已使用", telegramBindErrorFlowInvalid) + telegramBindFailure(c, telegramBindErrorFlowInvalid) return } if _, err := service.ValidateSessionReference(pendingFlow.UserId, pendingFlow.SessionId); err != nil { - if common.GetTheme() != "default" { - telegramBindFailure(c, http.StatusForbidden, "创建绑定的登录会话已失效", telegramBindErrorSessionInvalid) - return - } if !errors.Is(err, service.ErrLoginSessionInvalid) && !errors.Is(err, service.ErrLoginSessionRevoked) && !errors.Is(err, model.ErrUserSessionInactive) && !errors.Is(err, gorm.ErrRecordNotFound) { common.SysError("TelegramBind session validation failed: " + err.Error()) - telegramBindFailure(c, http.StatusOK, "", telegramBindErrorInternal) + telegramBindFailure(c, telegramBindErrorInternal) return } @@ -131,21 +121,21 @@ func TelegramBind(c *gin.Context) { userErr := model.DB.First(&user, pendingFlow.UserId).Error switch { case errors.Is(userErr, gorm.ErrRecordNotFound): - telegramBindFailure(c, http.StatusOK, "用户已注销", telegramBindErrorUserDeleted) + telegramBindFailure(c, telegramBindErrorUserDeleted) case userErr != nil: common.SysError("TelegramBind user status lookup failed: " + userErr.Error()) - telegramBindFailure(c, http.StatusOK, "", telegramBindErrorInternal) + telegramBindFailure(c, telegramBindErrorInternal) case user.Status != common.UserStatusEnabled: - telegramBindFailure(c, http.StatusForbidden, "用户已被禁用", telegramBindErrorUserDisabled) + telegramBindFailure(c, telegramBindErrorUserDisabled) default: - telegramBindFailure(c, http.StatusForbidden, "创建绑定的登录会话已失效", telegramBindErrorSessionInvalid) + telegramBindFailure(c, telegramBindErrorSessionInvalid) } return } assertion, assertionExpiresAt, err := telegramAuthorizationClaim(params, time.Now()) if err != nil { common.SysLog("TelegramBind authorization claim failed: " + err.Error()) - telegramBindFailure(c, http.StatusForbidden, "无效的请求", telegramBindErrorInvalidRequest) + telegramBindFailure(c, telegramBindErrorInvalidRequest) return } _, err = model.ConsumeAuthFlowWithAction(c.Param("flow_token"), model.AuthFlowMatch{ @@ -212,42 +202,29 @@ func TelegramBind(c *gin.Context) { if err != nil { switch { case errors.Is(err, errTelegramBindAssertionInvalid): - telegramBindFailure(c, http.StatusForbidden, "绑定流程已过期或已使用", telegramBindErrorInvalidRequest) + telegramBindFailure(c, telegramBindErrorInvalidRequest) case errors.Is(err, errTelegramAccountAlreadyBound): - telegramBindFailure(c, http.StatusOK, "该 Telegram 账户已被绑定", telegramBindErrorAlreadyBound) + telegramBindFailure(c, telegramBindErrorAlreadyBound) case errors.Is(err, errTelegramBindUserDeleted): - telegramBindFailure(c, http.StatusOK, "用户已注销", telegramBindErrorUserDeleted) + telegramBindFailure(c, telegramBindErrorUserDeleted) case errors.Is(err, errTelegramBindUserDisabled): - telegramBindFailure(c, http.StatusForbidden, "用户已被禁用", telegramBindErrorUserDisabled) + telegramBindFailure(c, telegramBindErrorUserDisabled) case errors.Is(err, service.ErrLoginSessionRevoked): - telegramBindFailure(c, http.StatusForbidden, "创建绑定的登录会话已失效", telegramBindErrorSessionInvalid) + telegramBindFailure(c, telegramBindErrorSessionInvalid) case errors.Is(err, model.ErrAuthFlowInvalid), errors.Is(err, model.ErrAuthFlowExpired), errors.Is(err, model.ErrAuthFlowConsumed): - telegramBindFailure(c, http.StatusForbidden, "绑定流程已过期或已使用", telegramBindErrorFlowInvalid) + telegramBindFailure(c, telegramBindErrorFlowInvalid) default: - if common.GetTheme() == "default" { - common.SysError("TelegramBind failed: " + err.Error()) - telegramBindFailure(c, http.StatusOK, "", telegramBindErrorInternal) - } else { - common.ApiError(c, err) - } + common.SysError("TelegramBind failed: " + err.Error()) + telegramBindFailure(c, telegramBindErrorInternal) } return } - if common.GetTheme() == "default" { - callback := "/oauth/telegram?telegram_bind=success&flow_token=" + url.QueryEscape(c.Param("flow_token")) - c.Redirect(http.StatusFound, callback) - return - } - c.Redirect(http.StatusFound, "/console/personal") + callback := "/oauth/telegram?telegram_bind=success&flow_token=" + url.QueryEscape(c.Param("flow_token")) + c.Redirect(http.StatusFound, callback) } -func telegramBindFailure(c *gin.Context, status int, message string, errorCode string) { - if common.GetTheme() != "default" { - c.JSON(status, gin.H{"message": message, "success": false}) - return - } - +func telegramBindFailure(c *gin.Context, errorCode string) { query := url.Values{ "telegram_bind": {"error"}, "flow_token": {c.Param("flow_token")}, diff --git a/controller/telegram_test.go b/controller/telegram_test.go index 33d7c791dc58..506447241266 100644 --- a/controller/telegram_test.go +++ b/controller/telegram_test.go @@ -128,49 +128,30 @@ func assertTelegramBindRedirect(t *testing.T, response *httptest.ResponseRecorde } func TestTelegramBindFailureResponseContract(t *testing.T) { - previousTheme := common.GetTheme() - t.Cleanup(func() { common.SetTheme(previousTheme) }) - failures := []struct { name string - status int - message string errorCode string }{ - {name: "disabled", status: http.StatusOK, message: "disabled message", errorCode: telegramBindErrorDisabled}, - {name: "invalid request", status: http.StatusForbidden, message: "invalid message", errorCode: telegramBindErrorInvalidRequest}, - {name: "invalid flow", status: http.StatusForbidden, message: "flow message", errorCode: telegramBindErrorFlowInvalid}, - {name: "invalid session", status: http.StatusForbidden, message: "session message", errorCode: telegramBindErrorSessionInvalid}, - {name: "already bound", status: http.StatusOK, message: "bound message", errorCode: telegramBindErrorAlreadyBound}, - {name: "deleted user", status: http.StatusOK, message: "deleted message", errorCode: telegramBindErrorUserDeleted}, - {name: "disabled user", status: http.StatusForbidden, message: "user disabled message", errorCode: telegramBindErrorUserDisabled}, - {name: "internal error", status: http.StatusInternalServerError, message: "database detail", errorCode: telegramBindErrorInternal}, + {name: "disabled", errorCode: telegramBindErrorDisabled}, + {name: "invalid request", errorCode: telegramBindErrorInvalidRequest}, + {name: "invalid flow", errorCode: telegramBindErrorFlowInvalid}, + {name: "invalid session", errorCode: telegramBindErrorSessionInvalid}, + {name: "already bound", errorCode: telegramBindErrorAlreadyBound}, + {name: "deleted user", errorCode: telegramBindErrorUserDeleted}, + {name: "disabled user", errorCode: telegramBindErrorUserDisabled}, + {name: "internal error", errorCode: telegramBindErrorInternal}, } for _, failure := range failures { - t.Run(failure.name+" default", func(t *testing.T) { - common.SetTheme("default") + t.Run(failure.name, func(t *testing.T) { response := httptest.NewRecorder() context, _ := gin.CreateTestContext(response) context.Params = gin.Params{{Key: "flow_token", Value: "flow token"}} context.Request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/flow-token", nil) - telegramBindFailure(context, failure.status, failure.message, failure.errorCode) + telegramBindFailure(context, failure.errorCode) assertTelegramBindRedirect(t, response, "flow token", failure.errorCode) - assert.NotContains(t, response.Header().Get("Location"), failure.message) - assert.NotContains(t, response.Body.String(), failure.message) - }) - - t.Run(failure.name+" classic", func(t *testing.T) { - common.SetTheme("classic") - response := httptest.NewRecorder() - context, _ := gin.CreateTestContext(response) - - telegramBindFailure(context, failure.status, failure.message, failure.errorCode) - - assert.Equal(t, failure.status, response.Code) - assert.JSONEq(t, `{"message":`+strconv.Quote(failure.message)+`,"success":false}`, response.Body.String()) }) } } @@ -182,7 +163,6 @@ func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) { previousEnabled := common.TelegramOAuthEnabled previousToken := common.TelegramBotToken previousSecret := common.SessionSecret - previousTheme := common.GetTheme() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate( @@ -197,7 +177,6 @@ func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) { common.TelegramOAuthEnabled = true common.TelegramBotToken = "telegram-bind-test-token" common.SessionSecret = "telegram-bind-session-secret" - common.SetTheme("default") t.Cleanup(func() { model.DB = previousDB common.SetMainDatabaseType(previousType) @@ -205,7 +184,6 @@ func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) { common.TelegramOAuthEnabled = previousEnabled common.TelegramBotToken = previousToken common.SessionSecret = previousSecret - common.SetTheme(previousTheme) }) user := &model.User{ @@ -428,58 +406,4 @@ func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) { internalAssertionExpiry, )) - classicUser := &model.User{ - Username: "telegram-bind-classic-user", Password: "password-placeholder", Role: common.RoleCommonUser, - Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "telegram-bind-classic-user", - } - require.NoError(t, db.Create(classicUser).Error) - classicSession := &model.UserSession{ - SID: "telegram-bind-classic-session", UserID: classicUser.Id, Version: 1, - UserAuthVersion: classicUser.AuthVersion, Status: model.UserSessionStatusActive, - RefreshHash: "classic-refresh-hash", LoginMethod: "password", - CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), - } - require.NoError(t, model.CreateUserSession(classicSession)) - classicFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ - Purpose: model.AuthFlowPurposeTelegramBind, UserId: classicUser.Id, SessionId: classicSession.SID, - ExpiresAt: now.Add(time.Minute), - }) - require.NoError(t, err) - classicParams := signedTelegramAuthorization(common.TelegramBotToken, now) - classicParams.Set("id", "987654") - signTelegramAuthorization(common.TelegramBotToken, classicParams) - common.SetTheme("classic") - request = httptest.NewRequest( - http.MethodGet, - "/api/oauth/telegram/bind/"+classicFlowToken+"?"+classicParams.Encode(), - nil, - ) - response = httptest.NewRecorder() - router.ServeHTTP(response, request) - assert.Equal(t, http.StatusFound, response.Code) - assert.Equal(t, "/console/personal", response.Header().Get("Location")) - - classicReplayFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ - Purpose: model.AuthFlowPurposeTelegramBind, UserId: classicUser.Id, SessionId: classicSession.SID, - ExpiresAt: now.Add(time.Minute), - }) - require.NoError(t, err) - request = httptest.NewRequest( - http.MethodGet, - "/api/oauth/telegram/bind/"+classicReplayFlowToken+"?"+classicParams.Encode(), - nil, - ) - response = httptest.NewRecorder() - router.ServeHTTP(response, request) - assert.Equal(t, http.StatusForbidden, response.Code) - assert.JSONEq(t, `{"message":"绑定流程已过期或已使用","success":false}`, response.Body.String()) - classicReplayFlow, err := model.GetAuthFlow(classicReplayFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind}) - require.NoError(t, err) - assert.Nil(t, classicReplayFlow.ConsumedAt) - - request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/classic-invalid", nil) - response = httptest.NewRecorder() - router.ServeHTTP(response, request) - assert.Equal(t, http.StatusOK, response.Code) - assert.JSONEq(t, `{"message":"无效的请求","success":false}`, response.Body.String()) } diff --git a/controller/theme_compat_test.go b/controller/theme_compat_test.go new file mode 100644 index 000000000000..28cb823ec957 --- /dev/null +++ b/controller/theme_compat_test.go @@ -0,0 +1,47 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdateOptionRejectsRetiredFrontendTheme(t *testing.T) { + response := httptest.NewRecorder() + context, _ := gin.CreateTestContext(response) + context.Request = httptest.NewRequest( + http.MethodPut, + "/api/option/", + strings.NewReader(`{"key":"theme.frontend","value":"classic"}`), + ) + + UpdateOption(context) + + assert.Equal(t, http.StatusOK, response.Code) + assert.JSONEq(t, `{"success":false,"message":"Classic 前端已移除,主题只能设置为 default"}`, response.Body.String()) +} + +func TestGetStatusAdvertisesDefaultDashboard(t *testing.T) { + previousMap := common.OptionMap + common.OptionMap = map[string]string{} + t.Cleanup(func() { common.OptionMap = previousMap }) + response := httptest.NewRecorder() + context, _ := gin.CreateTestContext(response) + context.Request = httptest.NewRequest(http.MethodGet, "/api/status", nil) + + GetStatus(context) + + var payload struct { + Success bool `json:"success"` + Data map[string]any `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload)) + assert.True(t, payload.Success) + assert.Equal(t, "default", payload.Data["theme"]) +} diff --git a/controller/topup.go b/controller/topup.go index 69e1b5e304c4..390f53f7dce8 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -45,14 +45,14 @@ func GetTopUpInfo(c *gin.Context) { stripeMethod := map[string]string{ "name": "Stripe", "type": "stripe", - "color": "rgba(var(--semi-purple-5), 1)", + "color": "#635BFF", "min_topup": strconv.Itoa(setting.StripeMinTopUp), } payMethods = append(payMethods, stripeMethod) } } - // Waffo Pancake displayed above the legacy Waffo gateway. + // Waffo Pancake is displayed above the standard Waffo gateway. enableWaffoPancake := isWaffoPancakeTopUpEnabled() if enableWaffoPancake { hasWaffoPancake := false @@ -67,7 +67,7 @@ func GetTopUpInfo(c *gin.Context) { payMethods = append(payMethods, map[string]string{ "name": "Waffo Pancake", "type": model.PaymentMethodWaffoPancake, - "color": "rgba(var(--semi-orange-5), 1)", + "color": "#F97316", "min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp), }) } @@ -88,7 +88,7 @@ func GetTopUpInfo(c *gin.Context) { waffoMethod := map[string]string{ "name": "Waffo (Global Payment)", "type": model.PaymentMethodWaffo, - "color": "rgba(var(--semi-blue-5), 1)", + "color": "#3B82F6", "min_topup": strconv.Itoa(setting.WaffoMinTopUp), } payMethods = append(payMethods, waffoMethod) @@ -216,7 +216,7 @@ func RequestEpay(c *gin.Context) { } callBackAddress := service.GetCallbackAddress() - returnUrl, _ := url.Parse(paymentReturnPath("/console/log")) + returnUrl, _ := url.Parse(paymentReturnPath("/usage-logs")) notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index bcae201ec4f3..8a39576659e6 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -347,10 +347,10 @@ func genStripeLink(referenceId string, customerId string, email string, amount i // Use custom URLs if provided, otherwise use defaults if successURL == "" { - successURL = paymentReturnPath("/console/log") + successURL = paymentReturnPath("/usage-logs") } if cancelURL == "" { - cancelURL = paymentReturnPath("/console/topup") + cancelURL = paymentReturnPath("/wallet") } params := &stripe.CheckoutSessionParams{ diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go index 9c646bd611cb..4ac3b2b5ddd7 100644 --- a/controller/topup_waffo.go +++ b/controller/topup_waffo.go @@ -248,7 +248,7 @@ func RequestWaffoPay(c *gin.Context) { if setting.WaffoNotifyUrl != "" { notifyUrl = setting.WaffoNotifyUrl } - returnUrl := paymentReturnPath("/console/topup?show_history=true") + returnUrl := paymentReturnPath("/wallet?show_history=true") if setting.WaffoReturnUrl != "" { returnUrl = setting.WaffoReturnUrl } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 21a38a223208..6438b0c4558e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,8 +2,8 @@ # # Usage: # 1. docker compose -f docker-compose.dev.yml up -d -# 2. cd web && bun install && bun run dev -# 3. Open http://localhost:3001 (Rsbuild dev server, API auto-proxied to :3000) +# 2. make dev-web +# 3. Open http://localhost:5173 (Rsbuild dev server, API auto-proxied to :3000) # # Rebuild backend after Go code changes: # docker compose -f docker-compose.dev.yml up -d --build new-api @@ -31,7 +31,7 @@ services: - REDIS_CONN_STRING=redis://redis - TZ=Asia/Shanghai - BATCH_UPDATE_ENABLED=true - # Local HTTP dev mode: keep Secure=false and leave TRUSTED_URL unset. This disables the refresh/logout OriginGuard so the :3001 -> :3000 dev proxy works. + # Local HTTP dev mode: keep Secure=false and leave TRUSTED_URL unset. This disables the refresh/logout OriginGuard so the :5173 -> :3000 dev proxy works. - SESSION_COOKIE_SECURE=false # For HTTPS only: set Secure=true and list every exact trusted HTTPS browser Origin. This does not configure relay CORS. # - SESSION_COOKIE_SECURE=true diff --git a/docs/openapi/api.json b/docs/openapi/api.json index fb59e44436ad..8f5bc89e9b38 100644 --- a/docs/openapi/api.json +++ b/docs/openapi/api.json @@ -1293,7 +1293,7 @@ "get": { "summary": "完成 Telegram 绑定", "deprecated": false, - "description": "Telegram widget 回调;通过一次性 flow_token 关联并重新校验创建该流程的登录会话。Default 主题始终以 302 回到 /oauth/telegram:成功携带 telegram_bind=success,失败携带 telegram_bind=error、flow_token 和稳定 error_code(TELEGRAM_BIND_DISABLED、TELEGRAM_BIND_INVALID_REQUEST、TELEGRAM_BIND_FLOW_INVALID、TELEGRAM_BIND_SESSION_INVALID、TELEGRAM_BIND_ALREADY_BOUND、TELEGRAM_BIND_USER_DELETED、TELEGRAM_BIND_USER_DISABLED 或 TELEGRAM_BIND_INTERNAL_ERROR),不会透传底层错误文案。Classic 主题保留既有 JSON 错误响应和成功跳转。", + "description": "Telegram widget 回调;通过一次性 flow_token 关联并重新校验创建该流程的登录会话。始终以 302 回到 /oauth/telegram:成功携带 telegram_bind=success,失败携带 telegram_bind=error、flow_token 和稳定 error_code(TELEGRAM_BIND_DISABLED、TELEGRAM_BIND_INVALID_REQUEST、TELEGRAM_BIND_FLOW_INVALID、TELEGRAM_BIND_SESSION_INVALID、TELEGRAM_BIND_ALREADY_BOUND、TELEGRAM_BIND_USER_DELETED、TELEGRAM_BIND_USER_DISABLED 或 TELEGRAM_BIND_INTERNAL_ERROR),不会透传底层错误文案。", "tags": [ "OAuth" ], @@ -1309,19 +1309,7 @@ ], "responses": { "302": { - "description": "Default 主题重定向到绑定结果回调;Classic 主题绑定成功后重定向到个人设置", - "headers": {} - }, - "200": { - "description": "Classic 主题的既有业务错误 JSON 响应", - "headers": {} - }, - "403": { - "description": "Classic 主题的既有无效流程、会话或授权 JSON 响应", - "headers": {} - }, - "500": { - "description": "Classic 主题的既有内部错误 JSON 响应", + "description": "重定向到 /oauth/telegram 的绑定结果回调", "headers": {} } }, @@ -4193,18 +4181,30 @@ "Combination1243": [] } ] - }, - "delete": { - "summary": "删除历史日志", + } + }, + "/api/system-task/log-cleanup": { + "post": { + "summary": "创建日志清理任务", "deprecated": false, - "description": "👨‍💼 需要管理员权限(Admin)", + "description": "👑 需要超级管理员权限(Root)。使用 target_timestamp 指定需清理的历史日志边界,并返回异步系统任务。", "tags": [ "日志" ], - "parameters": [], + "parameters": [ + { + "name": "target_timestamp", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], "responses": { "200": { - "description": "成功", + "description": "成功创建任务或返回参数错误", "headers": {} } }, @@ -5111,7 +5111,7 @@ "get": { "summary": "获取系统选项", "deprecated": false, - "description": "👑 需要超级管理员权限(Root)", + "description": "👑 需要超级管理员权限(Root)。已退役的 theme.frontend 不在列表中返回。", "tags": [ "系统设置" ], @@ -5134,7 +5134,7 @@ "put": { "summary": "更新系统选项", "deprecated": false, - "description": "👑 需要超级管理员权限(Root)", + "description": "👑 需要超级管理员权限(Root)。兼容期内 theme.frontend=default 可幂等写入,其他主题值会被拒绝。", "tags": [ "系统设置" ], @@ -5180,31 +5180,6 @@ ] } }, - "/api/option/migrate_console_setting": { - "post": { - "summary": "迁移控制台设置", - "deprecated": false, - "description": "👑 需要超级管理员权限(Root)", - "tags": [ - "系统设置" - ], - "parameters": [], - "responses": { - "200": { - "description": "成功", - "headers": {} - } - }, - "security": [ - { - "Combination343": [] - }, - { - "Combination1243": [] - } - ] - } - }, "/api/ratio_sync/channels": { "get": { "summary": "获取可同步渠道", diff --git a/electron/README.md b/electron/README.md index 88463b8aefd9..1cacd0c65f88 100644 --- a/electron/README.md +++ b/electron/README.md @@ -16,7 +16,7 @@ cp ../new-api-macos ../new-api **Option B: Build from source (requires Go)** TODO -### 3. Electron Dependencies +### 2. Electron Dependencies ```bash cd electron npm install @@ -24,13 +24,21 @@ npm install ## Development -Run the app in development mode: +Start the backend, the frontend, and Electron in separate terminals: ```bash -npm start +# Repository root +go run main.go + +# Repository root +make dev-web + +# electron/ +npm run dev-app ``` This will: -- Start the Go backend on port 3000 +- Use the Go backend on port 3000 +- Use the Rsbuild frontend development server on port 5173 - Open an Electron window with DevTools enabled - Create a system tray icon (menu bar on macOS) - Store database in `../data/new-api.db` @@ -39,10 +47,10 @@ This will: ### Quick Build ```bash -# Ensure Go binary exists in parent directory -ls ../new-api # Should exist +# From electron/, build the frontend, Go binary, and desktop package +./build.sh -# Build for current platform +# Or package an existing binary for the current platform npm run build # Platform-specific builds diff --git a/electron/build.sh b/electron/build.sh index cef714328d5a..001ad587a153 100755 --- a/electron/build.sh +++ b/electron/build.sh @@ -6,7 +6,8 @@ echo "Building New API Electron App..." echo "Step 1: Building frontend..." cd ../web -DISABLE_ESLINT_PLUGIN='true' bun run build +bun install --frozen-lockfile +DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags --always) bun run build cd ../electron echo "Step 2: Building Go backend..." @@ -38,4 +39,4 @@ else npm run build fi -echo "Build complete! Check electron/dist/ for output." \ No newline at end of file +echo "Build complete! Check electron/dist/ for output." diff --git a/electron/main.js b/electron/main.js index 210a4565852d..2200f643c889 100644 --- a/electron/main.js +++ b/electron/main.js @@ -9,7 +9,7 @@ let serverProcess; let tray = null; let serverErrorLogs = []; const PORT = 3000; -const DEV_FRONTEND_PORT = 5173; // Vite dev server port +const DEV_FRONTEND_PORT = 5173; // Rsbuild dev server port // 保存日志到文件并打开 function saveAndOpenErrorLog() { @@ -235,7 +235,7 @@ function startServer() { console.log('Development mode: skipping server startup'); console.log('Please make sure you have started:'); console.log(' 1. Go backend: go run main.go (port 3000)'); - console.log(' 2. Frontend dev server: cd web && bun dev (port 5173)'); + console.log(' 2. Frontend dev server: make dev-web (port 5173)'); console.log(''); console.log('Checking if servers are running...'); @@ -248,7 +248,7 @@ function startServer() { .catch((err) => { console.error(`✗ Cannot connect to frontend dev server on port ${DEV_FRONTEND_PORT}`); console.error('Please make sure the frontend dev server is running:'); - console.error(' cd web && bun dev'); + console.error(' make dev-web'); reject(err); }); return; @@ -587,4 +587,4 @@ app.on('before-quit', (event) => { app.exit(); }); } -}); \ No newline at end of file +}); diff --git a/electron/package.json b/electron/package.json index 137785b24130..7dc24c8d1c81 100644 --- a/electron/package.json +++ b/electron/package.json @@ -60,10 +60,6 @@ "from": "../new-api", "to": "bin/new-api" }, - { - "from": "../web/dist", - "to": "web/dist" - }, { "from": "../LICENSE", "to": "licenses/LICENSE" diff --git a/main.go b/main.go index b5cfff19bb2c..548034a19307 100644 --- a/main.go +++ b/main.go @@ -38,18 +38,12 @@ import ( _ "net/http/pprof" ) -//go:embed web/default/dist +//go:embed web/dist var buildFS embed.FS -//go:embed web/default/dist/index.html +//go:embed web/dist/index.html var indexPage []byte -//go:embed web/classic/dist -var classicBuildFS embed.FS - -//go:embed web/classic/dist/index.html -var classicIndexPage []byte - func main() { startTime := time.Now() @@ -194,11 +188,9 @@ func main() { InjectGoogleAnalytics() // 设置路由 - router.SetRouter(server, router.ThemeAssets{ - DefaultBuildFS: buildFS, - DefaultIndexPage: indexPage, - ClassicBuildFS: classicBuildFS, - ClassicIndexPage: classicIndexPage, + router.SetRouter(server, router.WebAssets{ + BuildFS: buildFS, + IndexPage: indexPage, }) var port = os.Getenv("PORT") if port == "" { @@ -257,7 +249,6 @@ func InjectUmamiAnalytics() { analyticsInject := []byte(analyticsInjectBuilder.String()) placeholder := []byte("\n") indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject) - classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject) } func InjectGoogleAnalytics() { @@ -281,7 +272,6 @@ func InjectGoogleAnalytics() { analyticsInject := []byte(analyticsInjectBuilder.String()) placeholder := []byte("\n") indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject) - classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject) } func InitResources() error { @@ -320,6 +310,11 @@ func InitResources() error { model.CheckSetup() // Initialize options, should after model.InitDB() + if common.IsMasterNode { + if err := model.MigrateRetiredFrontendOptions(); err != nil { + common.SysError("failed to migrate retired frontend options: " + err.Error()) + } + } model.InitOptionMap() // 清理旧的磁盘缓存文件 diff --git a/makefile b/makefile index 58c4ae4c6677..bcbd02b8f796 100644 --- a/makefile +++ b/makefile @@ -1,8 +1,6 @@ -WEB_DIR = ./web/default -WEB_CLASSIC_DIR = ./web/classic +WEB_DIR = ./web API_DIR = . -DEV_WEB_DEFAULT_PORT ?= 5173 -DEV_WEB_CLASSIC_PORT ?= 5174 +DEV_WEB_PORT ?= 5173 DEV_COMPOSE_FILE = docker-compose.dev.yml DEV_POSTGRES_SERVICE = postgres DEV_API_SERVICE = new-api @@ -10,21 +8,16 @@ DEV_POSTGRES_DB = new-api DEV_POSTGRES_USER = root DEV_SQLITE_PATH ?= one-api.db -.PHONY: all build-web build-web-classic build-all-web start-api dev dev-api dev-api-rebuild dev-web dev-web-classic reset-setup +.PHONY: all build-web build-all-web start-api dev dev-api dev-api-rebuild dev-web reset-setup all: build-all-web start-api build-web: - @echo "Building default web..." - @cd ./web && bun install --frozen-lockfile - @cd $(WEB_DIR) && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build + @echo "Building web frontend..." + @cd $(WEB_DIR) && bun install --frozen-lockfile + @cd $(WEB_DIR) && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$$(cat ../VERSION) bun run build -build-web-classic: - @echo "Building classic web..." - @cd ./web && bun install --frozen-lockfile - @cd $(WEB_CLASSIC_DIR) && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build - -build-all-web: build-web build-web-classic +build-all-web: build-web start-api: @echo "Starting api dev server..." @@ -39,15 +32,10 @@ dev-api-rebuild: @docker compose -f $(DEV_COMPOSE_FILE) up -d --build $(DEV_API_SERVICE) dev-web: - @echo "Starting default web dev server..." - @echo "Default web: http://localhost:$(DEV_WEB_DEFAULT_PORT)" - @cd ./web && bun install --filter ./default - @cd $(WEB_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_WEB_DEFAULT_PORT) - -dev-web-classic: - @echo "Starting classic web dev server..." - @cd ./web && bun install --filter ./classic - @cd $(WEB_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_WEB_CLASSIC_PORT) + @echo "Starting web frontend dev server..." + @echo "Web frontend: http://localhost:$(DEV_WEB_PORT)" + @cd $(WEB_DIR) && bun install + @cd $(WEB_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_WEB_PORT) dev: dev-api dev-web diff --git a/middleware/audit.go b/middleware/audit.go index 71b5cc43859c..851e48358a97 100644 --- a/middleware/audit.go +++ b/middleware/audit.go @@ -93,7 +93,7 @@ var auditRouteActions = map[string]string{ "POST /api/subscription/admin/bind": "subscription.bind", // 日志 - "DELETE /api/log/": "log.clear", + "POST /api/system-task/log-cleanup": "log.cleanup_start", } // beginAdminAudit 在管理/root 写操作进入 handler 前包装 ResponseWriter, @@ -155,7 +155,7 @@ func finishAdminAudit(c *gin.Context, writer *auditResponseWriter) { opParams["route"] = route } - // content 为英文兜底文本(导出/经典前端用)。 + // content 为英文兜底文本(供导出等非本地化消费者使用)。 content := method + " " + route adminInfo := map[string]interface{}{ diff --git a/model/frontend_option_migration.go b/model/frontend_option_migration.go new file mode 100644 index 000000000000..e8ac9f27e112 --- /dev/null +++ b/model/frontend_option_migration.go @@ -0,0 +1,239 @@ +package model + +import ( + "errors" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/console_setting" + "gorm.io/gorm" +) + +const retiredThemeOptionKey = "theme.frontend" + +type legacyOptionTransform func(string) (string, error) + +// MigrateRetiredFrontendOptions normalizes options that belonged to the +// removed dashboard frontend. Each legacy console setting is migrated in its +// own transaction so one malformed value cannot block the other settings. +func MigrateRetiredFrontendOptions() error { + if DB == nil { + return errors.New("database is not initialized") + } + + var migrationErrors []error + if err := normalizeRetiredThemeOption(); err != nil { + migrationErrors = append(migrationErrors, fmt.Errorf("normalize %s: %w", retiredThemeOptionKey, err)) + } + + migrations := []struct { + source string + target string + transform legacyOptionTransform + }{ + {source: "ApiInfo", target: "console_setting.api_info", transform: transformLegacyAPIInfo}, + {source: "Announcements", target: "console_setting.announcements", transform: transformLegacyAnnouncements}, + {source: "FAQ", target: "console_setting.faq", transform: transformLegacyFAQ}, + } + for _, migration := range migrations { + if err := migrateLegacyOption(migration.source, migration.target, migration.transform); err != nil { + migrationErrors = append(migrationErrors, err) + } + } + if err := migrateLegacyUptimeOptions(); err != nil { + migrationErrors = append(migrationErrors, err) + } + return errors.Join(migrationErrors...) +} + +func normalizeRetiredThemeOption() error { + return DB.Transaction(func(tx *gorm.DB) error { + var option Option + err := tx.Where(&Option{Key: retiredThemeOptionKey}).First(&option).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return tx.Create(&Option{Key: retiredThemeOptionKey, Value: "default"}).Error + } + if err != nil { + return err + } + if option.Value == "default" { + return nil + } + return tx.Model(&option).Update("value", "default").Error + }) +} + +func migrateLegacyOption(sourceKey, targetKey string, transform legacyOptionTransform) error { + return DB.Transaction(func(tx *gorm.DB) error { + var source Option + if err := tx.Where(&Option{Key: sourceKey}).First(&source).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return fmt.Errorf("read legacy option %s: %w", sourceKey, err) + } + + var target Option + err := tx.Where(&Option{Key: targetKey}).First(&target).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("read target option %s: %w", targetKey, err) + } + if err == nil { + return tx.Delete(&source).Error + } + + value, transformErr := transform(source.Value) + if transformErr != nil { + common.SysError(fmt.Sprintf("legacy option %s was not migrated: %v", sourceKey, transformErr)) + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + target = Option{Key: targetKey} + } + target.Value = value + if err := tx.Save(&target).Error; err != nil { + return fmt.Errorf("write target option %s: %w", targetKey, err) + } + if err := tx.Delete(&source).Error; err != nil { + return fmt.Errorf("delete legacy option %s: %w", sourceKey, err) + } + return nil + }) +} + +func transformLegacyAPIInfo(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "", errors.New("value is empty") + } + var items []map[string]any + if err := common.UnmarshalJsonStr(value, &items); err != nil { + return "", err + } + if len(items) > 50 { + items = items[:50] + } + encoded, err := common.Marshal(items) + if err != nil { + return "", err + } + result := string(encoded) + if err := console_setting.ValidateConsoleSettings(result, "ApiInfo"); err != nil { + return "", err + } + return result, nil +} + +func transformLegacyAnnouncements(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "", errors.New("value is empty") + } + if err := console_setting.ValidateConsoleSettings(value, "Announcements"); err != nil { + return "", err + } + return value, nil +} + +func transformLegacyFAQ(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "", errors.New("value is empty") + } + var legacyItems []map[string]any + if err := common.UnmarshalJsonStr(value, &legacyItems); err != nil { + return "", err + } + items := make([]map[string]any, 0, len(legacyItems)) + for index, item := range legacyItems { + question, _ := item["question"].(string) + if strings.TrimSpace(question) == "" { + question, _ = item["title"].(string) + } + answer, _ := item["answer"].(string) + if strings.TrimSpace(answer) == "" { + answer, _ = item["content"].(string) + } + if strings.TrimSpace(question) == "" || strings.TrimSpace(answer) == "" { + return "", fmt.Errorf("FAQ entry %d is missing a question or answer", index) + } + items = append(items, map[string]any{"question": question, "answer": answer}) + } + if len(items) > 50 { + items = items[:50] + } + encoded, err := common.Marshal(items) + if err != nil { + return "", err + } + result := string(encoded) + if err := console_setting.ValidateConsoleSettings(result, "FAQ"); err != nil { + return "", err + } + return result, nil +} + +func migrateLegacyUptimeOptions() error { + return DB.Transaction(func(tx *gorm.DB) error { + var urlOption Option + urlErr := tx.Where(&Option{Key: "UptimeKumaUrl"}).First(&urlOption).Error + if urlErr != nil && !errors.Is(urlErr, gorm.ErrRecordNotFound) { + return fmt.Errorf("read legacy option UptimeKumaUrl: %w", urlErr) + } + var slugOption Option + slugErr := tx.Where(&Option{Key: "UptimeKumaSlug"}).First(&slugOption).Error + if slugErr != nil && !errors.Is(slugErr, gorm.ErrRecordNotFound) { + return fmt.Errorf("read legacy option UptimeKumaSlug: %w", slugErr) + } + if errors.Is(urlErr, gorm.ErrRecordNotFound) && errors.Is(slugErr, gorm.ErrRecordNotFound) { + return nil + } + + var target Option + targetErr := tx.Where(&Option{Key: "console_setting.uptime_kuma_groups"}).First(&target).Error + if targetErr != nil && !errors.Is(targetErr, gorm.ErrRecordNotFound) { + return fmt.Errorf("read target option console_setting.uptime_kuma_groups: %w", targetErr) + } + if targetErr == nil { + if urlErr == nil { + if err := tx.Delete(&urlOption).Error; err != nil { + return err + } + } + if slugErr == nil { + return tx.Delete(&slugOption).Error + } + return nil + } + + if urlErr != nil || slugErr != nil || strings.TrimSpace(urlOption.Value) == "" || strings.TrimSpace(slugOption.Value) == "" { + common.SysError("legacy Uptime Kuma options were not migrated: both URL and slug are required") + return nil + } + groups := []map[string]any{{ + "id": 1, + "categoryName": "old", + "url": urlOption.Value, + "slug": slugOption.Value, + "description": "", + }} + encoded, err := common.Marshal(groups) + if err != nil { + return err + } + value := string(encoded) + if err := console_setting.ValidateConsoleSettings(value, "UptimeKumaGroups"); err != nil { + common.SysError(fmt.Sprintf("legacy Uptime Kuma options were not migrated: %v", err)) + return nil + } + if errors.Is(targetErr, gorm.ErrRecordNotFound) { + target = Option{Key: "console_setting.uptime_kuma_groups"} + } + target.Value = value + if err := tx.Save(&target).Error; err != nil { + return fmt.Errorf("write target option console_setting.uptime_kuma_groups: %w", err) + } + if err := tx.Delete(&urlOption).Error; err != nil { + return err + } + return tx.Delete(&slugOption).Error + }) +} diff --git a/model/frontend_option_migration_test.go b/model/frontend_option_migration_test.go new file mode 100644 index 000000000000..130c1b116db7 --- /dev/null +++ b/model/frontend_option_migration_test.go @@ -0,0 +1,180 @@ +package model + +import ( + "fmt" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func useFrontendOptionMigrationDB(t *testing.T) *gorm.DB { + t.Helper() + previousDB := DB + previousType := common.MainDatabaseType() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&Option{})) + DB = db + common.SetMainDatabaseType(common.DatabaseTypeSQLite) + t.Cleanup(func() { + DB = previousDB + common.SetMainDatabaseType(previousType) + }) + return db +} + +func requireOptionValue(t *testing.T, db *gorm.DB, key string) string { + t.Helper() + var option Option + require.NoError(t, db.Where(&Option{Key: key}).First(&option).Error) + return option.Value +} + +func requireOptionMissing(t *testing.T, db *gorm.DB, key string) { + t.Helper() + var option Option + assert.ErrorIs(t, db.Where(&Option{Key: key}).First(&option).Error, gorm.ErrRecordNotFound) +} + +func TestMigrateRetiredFrontendOptionsMigratesValidValuesIdempotently(t *testing.T) { + db := useFrontendOptionMigrationDB(t) + legacy := []Option{ + {Key: retiredThemeOptionKey, Value: "classic"}, + {Key: "ApiInfo", Value: `[{"url":"https://api.example.com","route":"primary","description":"API","color":"blue"}]`}, + {Key: "Announcements", Value: `[{"content":"maintenance","publishDate":"2026-07-20T00:00:00Z","type":"warning"}]`}, + {Key: "FAQ", Value: `[{"title":"Question","content":"Answer"}]`}, + {Key: "UptimeKumaUrl", Value: "https://status.example.com"}, + {Key: "UptimeKumaSlug", Value: "status"}, + } + require.NoError(t, db.Create(&legacy).Error) + + require.NoError(t, MigrateRetiredFrontendOptions()) + assert.Equal(t, "default", requireOptionValue(t, db, retiredThemeOptionKey)) + assert.JSONEq(t, legacy[1].Value, requireOptionValue(t, db, "console_setting.api_info")) + assert.Equal(t, legacy[2].Value, requireOptionValue(t, db, "console_setting.announcements")) + assert.JSONEq(t, `[{"question":"Question","answer":"Answer"}]`, requireOptionValue(t, db, "console_setting.faq")) + assert.JSONEq(t, `[{ + "id":1,"categoryName":"old","url":"https://status.example.com","slug":"status","description":"" + }]`, requireOptionValue(t, db, "console_setting.uptime_kuma_groups")) + for _, key := range []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"} { + requireOptionMissing(t, db, key) + } + + before, err := AllOption() + require.NoError(t, err) + require.NoError(t, MigrateRetiredFrontendOptions()) + after, err := AllOption() + require.NoError(t, err) + assert.ElementsMatch(t, before, after) +} + +func TestLegacyConsoleListMigrationCapsAPIInfoAndFAQ(t *testing.T) { + apiInfo := make([]map[string]any, 51) + faq := make([]map[string]any, 51) + for i := range apiInfo { + apiInfo[i] = map[string]any{ + "url": fmt.Sprintf("https://api-%d.example.com", i), + "route": fmt.Sprintf("route-%d", i), + "description": "API", + "color": "blue", + } + faq[i] = map[string]any{"title": fmt.Sprintf("Question %d", i), "content": "Answer"} + } + apiBytes, err := common.Marshal(apiInfo) + require.NoError(t, err) + faqBytes, err := common.Marshal(faq) + require.NoError(t, err) + + migratedAPI, err := transformLegacyAPIInfo(string(apiBytes)) + require.NoError(t, err) + migratedFAQ, err := transformLegacyFAQ(string(faqBytes)) + require.NoError(t, err) + var apiResult []map[string]any + require.NoError(t, common.UnmarshalJsonStr(migratedAPI, &apiResult)) + var faqResult []map[string]any + require.NoError(t, common.UnmarshalJsonStr(migratedFAQ, &faqResult)) + assert.Len(t, apiResult, 50) + assert.Len(t, faqResult, 50) +} + +func TestMigrateRetiredFrontendOptionsPreservesMalformedValuesAndContinues(t *testing.T) { + db := useFrontendOptionMigrationDB(t) + legacy := []Option{ + {Key: "ApiInfo", Value: `{invalid`}, + {Key: "FAQ", Value: `[{"question":"Question","answer":"Answer"}]`}, + {Key: "UptimeKumaUrl", Value: "https://status.example.com"}, + } + require.NoError(t, db.Create(&legacy).Error) + + require.NoError(t, MigrateRetiredFrontendOptions()) + assert.Equal(t, `{invalid`, requireOptionValue(t, db, "ApiInfo")) + requireOptionMissing(t, db, "console_setting.api_info") + requireOptionMissing(t, db, "FAQ") + assert.JSONEq(t, legacy[1].Value, requireOptionValue(t, db, "console_setting.faq")) + assert.Equal(t, "https://status.example.com", requireOptionValue(t, db, "UptimeKumaUrl")) + requireOptionMissing(t, db, "console_setting.uptime_kuma_groups") +} + +func TestMigrateRetiredFrontendOptionsPreservesMixedInvalidFAQ(t *testing.T) { + db := useFrontendOptionMigrationDB(t) + legacyFAQ := `[{"question":"Valid question","answer":"Valid answer"},{"question":"Missing answer"}]` + require.NoError(t, db.Create(&Option{Key: "FAQ", Value: legacyFAQ}).Error) + + require.NoError(t, MigrateRetiredFrontendOptions()) + assert.Equal(t, legacyFAQ, requireOptionValue(t, db, "FAQ")) + requireOptionMissing(t, db, "console_setting.faq") +} + +func TestMigrateRetiredFrontendOptionsKeepsAuthoritativeTargets(t *testing.T) { + db := useFrontendOptionMigrationDB(t) + options := []Option{ + {Key: "ApiInfo", Value: `{invalid`}, + {Key: "console_setting.api_info", Value: `[{"url":"https://new.example.com"}]`}, + {Key: "UptimeKumaUrl", Value: "https://old.example.com"}, + {Key: "UptimeKumaSlug", Value: "old"}, + {Key: "console_setting.uptime_kuma_groups", Value: `[{"url":"https://new.example.com"}]`}, + } + require.NoError(t, db.Create(&options).Error) + + require.NoError(t, MigrateRetiredFrontendOptions()) + assert.Equal(t, options[1].Value, requireOptionValue(t, db, "console_setting.api_info")) + assert.Equal(t, options[4].Value, requireOptionValue(t, db, "console_setting.uptime_kuma_groups")) + for _, key := range []string{"ApiInfo", "UptimeKumaUrl", "UptimeKumaSlug"} { + requireOptionMissing(t, db, key) + } +} + +func TestMigrateRetiredFrontendOptionsKeepsEmptyAuthoritativeTargets(t *testing.T) { + db := useFrontendOptionMigrationDB(t) + options := []Option{ + {Key: "ApiInfo", Value: `[{"url":"https://old.example.com"}]`}, + {Key: "console_setting.api_info", Value: ""}, + {Key: "UptimeKumaUrl", Value: "https://old.example.com"}, + {Key: "UptimeKumaSlug", Value: "old"}, + {Key: "console_setting.uptime_kuma_groups", Value: ""}, + } + require.NoError(t, db.Create(&options).Error) + + require.NoError(t, MigrateRetiredFrontendOptions()) + assert.Empty(t, requireOptionValue(t, db, "console_setting.api_info")) + assert.Empty(t, requireOptionValue(t, db, "console_setting.uptime_kuma_groups")) + for _, key := range []string{"ApiInfo", "UptimeKumaUrl", "UptimeKumaSlug"} { + requireOptionMissing(t, db, key) + } +} + +func TestRetiredThemeOptionIsPersistedButNotPublished(t *testing.T) { + db := useFrontendOptionMigrationDB(t) + previousMap := common.OptionMap + t.Cleanup(func() { common.OptionMap = previousMap }) + common.OptionMap = map[string]string{} + + require.NoError(t, UpdateOption(retiredThemeOptionKey, "default")) + assert.Equal(t, "default", requireOptionValue(t, db, retiredThemeOptionKey)) + _, published := common.OptionMap[retiredThemeOptionKey] + assert.False(t, published) +} diff --git a/model/log.go b/model/log.go index 506bd504b686..401d53c435a5 100644 --- a/model/log.go +++ b/model/log.go @@ -198,7 +198,7 @@ func buildOpField(action string, params map[string]interface{}) map[string]inter // RecordLoginLog 记录用户登录成功的审计日志(type=LogTypeLogin)。 // username 由调用方传入(登录流程已持有用户对象),避免额外的数据库查询。 -// content 为英文兜底文本(用于导出/经典前端);action+params 供前端本地化渲染。 +// content 为英文兜底文本(用于导出);action+params 供前端本地化渲染。 // extra 可携带 login_method、user_agent 等附加信息(普通用户可见)。 func RecordLoginLog(userId int, username string, content string, ip string, action string, params map[string]interface{}, extra map[string]interface{}) { other := map[string]interface{}{} @@ -222,7 +222,7 @@ func RecordLoginLog(userId int, username string, content string, ip string, acti // RecordOperationAuditLog 记录管理/高危操作审计日志(type=LogTypeManage)。 // logUserId 为日志归属者,管理审计日志应归属实际操作者;目标资源/用户放入 -// action params。username 内部按 logUserId 查询。content 为英文兜底文本(导出/经典前端用)。 +// action params。username 内部按 logUserId 查询。content 为英文兜底文本(供导出使用)。 // action+params 写入 Other.op,供前端本地化渲染(普通用户可见,不含敏感信息)。 // adminInfo 存放操作者身份(写入 Other.admin_info,普通用户查询时剥离); // auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。 @@ -735,30 +735,3 @@ func DeleteOldLogBatch(ctx context.Context, targetTimestamp int64, limit int) (i } return result.RowsAffected, nil } - -func DeleteOldLog(ctx context.Context, targetTimestamp int64, limit int) (int64, error) { - if limit <= 0 { - limit = 100 - } - - var total int64 = 0 - - for { - if nil != ctx.Err() { - return total, ctx.Err() - } - - rowsAffected, err := DeleteOldLogBatch(ctx, targetTimestamp, limit) - if nil != err { - return total, err - } - - total += rowsAffected - - if rowsAffected < int64(limit) { - break - } - } - - return total, nil -} diff --git a/model/option.go b/model/option.go index 8e8587f271c8..89a233ec57c3 100644 --- a/model/option.go +++ b/model/option.go @@ -254,6 +254,12 @@ func UpdateOptionsBulk(values map[string]string) error { } func updateOptionMap(key string, value string) (err error) { + if key == retiredThemeOptionKey { + common.OptionMapRWMutex.Lock() + delete(common.OptionMap, key) + common.OptionMapRWMutex.Unlock() + return nil + } common.OptionMapRWMutex.Lock() defer common.OptionMapRWMutex.Unlock() common.OptionMap[key] = value @@ -606,8 +612,6 @@ func handleConfigUpdate(key, value string) bool { } else if configName == "billing_setting" { InvalidatePricingCache() ratio_setting.InvalidateExposedDataCache() - } else if configName == "theme" { - system_setting.UpdateAndSyncTheme() } return true // 已处理 diff --git a/router/api-router.go b/router/api-router.go index 2cc5e9bdbf63..80fd65178c44 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -197,7 +197,6 @@ func SetApiRouter(router *gin.Engine) { optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats) optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache) optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) - optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 optionRoute.GET("/waffo-pancake/catalog", controller.ListWaffoPancakeCatalog) optionRoute.POST("/waffo-pancake/pair", controller.CreateWaffoPancakePair) optionRoute.POST("/waffo-pancake/save", controller.SaveWaffoPancake) @@ -271,9 +270,6 @@ func SetApiRouter(router *gin.Engine) { } logRoute := apiRouter.Group("/log") logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs) - // Legacy synchronous direct-delete route used only by the classic frontend. - // TODO: remove once the classic frontend is removed; the default frontend uses /system-task/log-cleanup. - logRoute.DELETE("/", middleware.RootAuth(), controller.DeleteHistoryLogs) logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat) logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat) logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats) diff --git a/router/main.go b/router/main.go index d3769bd591ba..6eeeeb88502a 100644 --- a/router/main.go +++ b/router/main.go @@ -12,7 +12,7 @@ import ( "github.com/gin-gonic/gin" ) -func SetRouter(router *gin.Engine, assets ThemeAssets) { +func SetRouter(router *gin.Engine, assets WebAssets) { SetApiRouter(router) SetDashboardRouter(router) SetRelayRouter(router) diff --git a/router/retired_frontend_routes_test.go b/router/retired_frontend_routes_test.go new file mode 100644 index 000000000000..89514a644a08 --- /dev/null +++ b/router/retired_frontend_routes_test.go @@ -0,0 +1,26 @@ +package router + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestRetiredFrontendAPIRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + SetApiRouter(engine) + + routes := make(map[string]struct{}, len(engine.Routes())) + for _, route := range engine.Routes() { + routes[route.Method+" "+route.Path] = struct{}{} + } + _, hasAsyncCleanup := routes[http.MethodPost+" /api/system-task/log-cleanup"] + _, hasDirectDelete := routes[http.MethodDelete+" /api/log/"] + _, hasConsoleMigration := routes[http.MethodPost+" /api/option/migrate_console_setting"] + assert.True(t, hasAsyncCleanup) + assert.False(t, hasDirectDelete) + assert.False(t, hasConsoleMigration) +} diff --git a/router/web-router.go b/router/web-router.go index 0d475e90d54d..83c91d8fe155 100644 --- a/router/web-router.go +++ b/router/web-router.go @@ -13,23 +13,19 @@ import ( "github.com/gin-gonic/gin" ) -// ThemeAssets holds the embedded frontend assets for both themes. -type ThemeAssets struct { - DefaultBuildFS embed.FS - DefaultIndexPage []byte - ClassicBuildFS embed.FS - ClassicIndexPage []byte +// WebAssets holds the embedded dashboard frontend assets. +type WebAssets struct { + BuildFS embed.FS + IndexPage []byte } -func SetWebRouter(router *gin.Engine, assets ThemeAssets) { - defaultFS := common.EmbedFolder(assets.DefaultBuildFS, "web/default/dist") - classicFS := common.EmbedFolder(assets.ClassicBuildFS, "web/classic/dist") - themeFS := common.NewThemeAwareFS(defaultFS, classicFS) +func SetWebRouter(router *gin.Engine, assets WebAssets) { + frontendFS := common.EmbedFolder(assets.BuildFS, "web/dist") router.Use(gzip.Gzip(gzip.DefaultCompression)) router.Use(middleware.GlobalWebRateLimit()) router.Use(middleware.Cache()) - router.Use(static.Serve("/", themeFS)) + router.Use(static.Serve("/", frontendFS)) router.NoRoute(func(c *gin.Context) { c.Set(middleware.RouteTagKey, "web") if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") { @@ -37,10 +33,6 @@ func SetWebRouter(router *gin.Engine, assets ThemeAssets) { return } c.Header("Cache-Control", "no-cache") - if common.GetTheme() == "classic" { - c.Data(http.StatusOK, "text/html; charset=utf-8", assets.ClassicIndexPage) - } else { - c.Data(http.StatusOK, "text/html; charset=utf-8", assets.DefaultIndexPage) - } + c.Data(http.StatusOK, "text/html; charset=utf-8", assets.IndexPage) }) } diff --git a/service/quota.go b/service/quota.go index e5d4ec7e2330..84ef22b94dff 100644 --- a/service/quota.go +++ b/service/quota.go @@ -470,7 +470,7 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon } if quotaTooLow { prompt := "您的额度即将用尽" - topUpLink := PaymentReturnURL("/console/topup") + topUpLink := PaymentReturnURL("/wallet") // 根据通知方式生成不同的内容格式 var content string @@ -524,7 +524,7 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) { } prompt := "您的订阅额度即将用尽" - topUpLink := PaymentReturnURL("/console/topup") + topUpLink := PaymentReturnURL("/wallet") var content string var values []interface{} diff --git a/service/return_path.go b/service/return_path.go index c99e1fd34000..21b736d269f7 100644 --- a/service/return_path.go +++ b/service/return_path.go @@ -3,11 +3,10 @@ package service import ( "strings" - "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/setting/system_setting" ) func PaymentReturnURL(suffix string) string { base := strings.TrimRight(system_setting.ServerAddress, "/") - return base + common.ThemeAwarePath(suffix) + return base + suffix } diff --git a/service/return_path_test.go b/service/return_path_test.go new file mode 100644 index 000000000000..6039a171c0a1 --- /dev/null +++ b/service/return_path_test.go @@ -0,0 +1,16 @@ +package service + +import ( + "testing" + + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/stretchr/testify/assert" +) + +func TestPaymentReturnURLUsesSuppliedDefaultDashboardPath(t *testing.T) { + previousAddress := system_setting.ServerAddress + system_setting.ServerAddress = "https://dashboard.example.com/" + t.Cleanup(func() { system_setting.ServerAddress = previousAddress }) + + assert.Equal(t, "https://dashboard.example.com/wallet", PaymentReturnURL("/wallet")) +} diff --git a/setting/system_setting/theme.go b/setting/system_setting/theme.go deleted file mode 100644 index 44dfc142941d..000000000000 --- a/setting/system_setting/theme.go +++ /dev/null @@ -1,32 +0,0 @@ -package system_setting - -import ( - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/setting/config" -) - -type ThemeSettings struct { - Frontend string `json:"frontend"` -} - -var themeSettings = ThemeSettings{ - Frontend: "classic", -} - -func init() { - config.GlobalConfig.Register("theme", &themeSettings) - syncThemeToCommon() -} - -func syncThemeToCommon() { - common.SetTheme(themeSettings.Frontend) -} - -func GetThemeSettings() *ThemeSettings { - return &themeSettings -} - -// UpdateAndSyncTheme syncs the theme config to common after DB load. -func UpdateAndSyncTheme() { - syncThemeToCommon() -} diff --git a/web/default/.gitignore b/web/.gitignore similarity index 100% rename from web/default/.gitignore rename to web/.gitignore diff --git a/web/default/.node-version b/web/.node-version similarity index 100% rename from web/default/.node-version rename to web/.node-version diff --git a/web/default/.npmrc b/web/.npmrc similarity index 100% rename from web/default/.npmrc rename to web/.npmrc diff --git a/web/default/.oxfmtrc.json b/web/.oxfmtrc.json similarity index 100% rename from web/default/.oxfmtrc.json rename to web/.oxfmtrc.json diff --git a/web/default/.oxlintrc.json b/web/.oxlintrc.json similarity index 100% rename from web/default/.oxlintrc.json rename to web/.oxlintrc.json diff --git a/web/default/AGENTS.md b/web/AGENTS.md similarity index 99% rename from web/default/AGENTS.md rename to web/AGENTS.md index 4af5e86bfe9a..c026263fd124 100644 --- a/web/default/AGENTS.md +++ b/web/AGENTS.md @@ -64,7 +64,7 @@ - **专有名词**:品牌、产品、技术术语等可保留英文(如 API、React、TypeScript);若有约定俗成的译法则使用翻译。 - **翻译键**:使用有层级、语义清晰的键名,如 `dashboard.overview.title`,并保持命名一致。 -- **枚举与文案(常量中的 i18n)** +- **枚举与文案(常量中的 i18n)** 各 feature 的 `constants.ts` 中常出现「枚举/状态 + 展示文案」或「成功/错误消息」,须统一约定以免遗漏 i18n、用法混乱: - **成功/错误/提示类消息**(如 `SUCCESS_MESSAGES`、`ERROR_MESSAGES`):常量值仅表示 **i18n 键**(与英文 fallback 同字面量)。展示时**必须**通过 `t()` 使用,例如 `toast.success(t(SUCCESS_MESSAGES.API_KEY_CREATED))`、`toast.error(t(ERROR_MESSAGES.UNEXPECTED))`,**禁止**直接 `toast.success(SUCCESS_MESSAGES.xxx)` 当作最终文案。 - **状态/选项的 label**:在常量中统一用 **labelKey**(字符串,即 i18n 键),组件中通过 `t(config.labelKey)` 渲染;或约定用 `label` 存与 en 一致的 key 字符串,组件用 `t(config.label)`。同一 feature 内只采用一种方式,避免混用。 diff --git a/web/bun.lock b/web/bun.lock index d86f3a8b3ffe..783276cb2e39 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -1,72 +1,8 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { - "name": "new-api-web-workspace", - }, - "classic": { - "name": "react-template", - "version": "0.1.0", - "dependencies": { - "@douyinfe/semi-icons": "^2.63.1", - "@douyinfe/semi-illustrations": "^2.69.1", - "@douyinfe/semi-ui": "^2.69.1", - "@lobehub/icons": "catalog:", - "@visactor/react-vchart": "~1.8.8", - "@visactor/vchart": "~1.8.8", - "@visactor/vchart-semi-theme": "~1.8.8", - "axios": "catalog:", - "clsx": "catalog:", - "dayjs": "catalog:", - "highlight.js": "^11.11.1", - "history": "^5.3.0", - "i18next": "^23.16.8", - "i18next-browser-languagedetector": "^7.2.0", - "katex": "^0.16.22", - "lucide-react": "^0.511.0", - "marked": "^4.1.1", - "mermaid": "^11.6.0", - "qrcode.react": "catalog:", - "react": "catalog:", - "react-dom": "catalog:", - "react-dropzone": "^14.2.3", - "react-fireworks": "^1.0.4", - "react-i18next": "^13.0.0", - "react-icons": "catalog:", - "react-markdown": "catalog:", - "react-router-dom": "^6.3.0", - "react-telegram-login": "^1.1.2", - "react-toastify": "^9.0.8", - "react-turnstile": "^1.0.5", - "rehype-highlight": "^7.0.2", - "rehype-katex": "^7.0.1", - "remark-breaks": "^4.0.0", - "remark-gfm": "catalog:", - "remark-math": "^6.0.0", - "sse.js": "catalog:", - "unist-util-visit": "^5.0.0", - "use-debounce": "^10.0.4", - }, - "devDependencies": { - "@rsbuild/core": "catalog:", - "@rsbuild/plugin-react": "catalog:", - "@so1ve/prettier-config": "^3.1.0", - "autoprefixer": "^10.4.21", - "eslint": "8.57.0", - "eslint-plugin-header": "^3.1.1", - "eslint-plugin-react-hooks": "^5.2.0", - "i18next-cli": "^1.10.3", - "postcss": "^8.5.3", - "prettier": "catalog:", - "prop-types": "^15.8.1", - "tailwindcss": "^3", - "typescript": "4.4.2", - }, - }, - "default": { "name": "newapi-web", - "version": "1.0.0", "dependencies": { "@base-ui/react": "^1.6.0", "@codemirror/lang-markdown": "^6.5.0", @@ -79,7 +15,7 @@ "@hugeicons/core-free-icons": "^4.2.2", "@hugeicons/react": "^1.1.9", "@lezer/highlight": "^1.2.3", - "@lobehub/icons": "catalog:", + "@lobehub/icons": "^5.10.1", "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", "@tanstack/react-table": "^8.21.3", @@ -88,11 +24,11 @@ "@visactor/vchart": "^2.1.2", "ai": "^7.0.14", "auto-skeleton-react": "^1.0.5", - "axios": "catalog:", + "axios": "^1.18.1", "class-variance-authority": "^0.7.1", - "clsx": "catalog:", + "clsx": "^2.1.1", "cmdk": "^1.1.1", - "dayjs": "catalog:", + "dayjs": "^1.11.21", "dompurify": "3.4.11", "i18next": "^26.3.4", "i18next-browser-languagedetector": "^8.2.1", @@ -103,19 +39,19 @@ "motion": "^12.42.2", "nanoid": "^5.1.16", "next-themes": "^0.4.6", - "qrcode.react": "catalog:", - "react": "catalog:", + "qrcode.react": "^4.2.0", + "react": "^19.2.7", "react-day-picker": "^10.0.1", - "react-dom": "catalog:", + "react-dom": "^19.2.7", "react-hook-form": "^7.80.0", "react-i18next": "^17.0.8", - "react-icons": "catalog:", + "react-icons": "^5.7.0", "react-resizable-panels": "^4.12.0", "react-top-loading-bar": "^3.0.2", "recharts": "3.9.1", "shiki": "^4.3.0", "sonner": "^2.0.7", - "sse.js": "catalog:", + "sse.js": "^2.8.0", "stream-markdown-parser": "^1.0.9", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", @@ -127,9 +63,9 @@ "zustand": "^5.0.14", }, "devDependencies": { - "@rsbuild/core": "catalog:", - "@rsbuild/plugin-react": "catalog:", - "@rsbuild/plugin-tailwindcss": "catalog:", + "@rsbuild/core": "^2.1.4", + "@rsbuild/plugin-react": "^2.1.0", + "@rsbuild/plugin-tailwindcss": "^2.0.3", "@tanstack/react-query-devtools": "^5.101.2", "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.19", @@ -140,37 +76,31 @@ "@xyflow/react": "^12.11.1", "embla-carousel-react": "^8.6.0", "knip": "^6.24.0", - "oxfmt": "catalog:", - "oxlint": "catalog:", + "oxfmt": "^0.57.0", + "oxlint": "^1.72.0", "shadcn": "^4.12.0", }, }, }, - "catalog": { - "@lobehub/icons": "^5.10.1", - "@rsbuild/core": "^2.1.4", - "@rsbuild/plugin-react": "^2.1.0", - "@rsbuild/plugin-tailwindcss": "^2.0.3", - "axios": "^1.18.1", - "clsx": "^2.1.1", - "dayjs": "^1.11.21", - "oxfmt": "^0.57.0", - "oxlint": "^1.72.0", - "prettier": "^3.8.3", - "qrcode.react": "^4.2.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-icons": "^5.7.0", - "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1", - "sse.js": "^2.8.0", + "overrides": { + "brace-expansion": "2.1.1", + "dompurify": "3.4.11", + "fast-uri": "3.1.2", + "hono": "4.12.22", + "ip-address": "10.2.0", + "js-cookie": "3.0.7", + "mermaid": "11.15.0", + "minimist": "1.2.8", + "postcss": "8.5.15", + "qs": "6.15.2", + "uuid": "14.0.0", }, "packages": { - "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.11", "", { "dependencies": { "@ai-sdk/provider": "4.0.2", "@ai-sdk/provider-utils": "5.0.5", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZdZzQnBxYfjJpWpSNkV+rRWycwTBhxyvwGvxcwx9g+WQoy3MK2xNahSySEgP9hD/X2xY9jkjd0xB67oDE3rLMA=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.11", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-f85diFdPMXYJpxCjOYZchMQkRH8h3r6lhK4Q2xmzJ7UA2OQ80L3W7tFu61742xGQK7zHWm5AhxYhNuc50H9SGQ=="], - "@ai-sdk/provider": ["@ai-sdk/provider@4.0.2", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw=="], + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.5", "", { "dependencies": { "@ai-sdk/provider": "4.0.2", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.11", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7/96wE+ZsKB35iS9ASyllrE4Ym/EolXEB7AkuJ5FI++fmS85BVTAs77890C+1Z2jwHfBKjBQSBmsliOsAh0iFQ=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -182,16 +112,14 @@ "@ant-design/fast-color": ["@ant-design/fast-color@3.0.1", "", {}, "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw=="], - "@ant-design/icons": ["@ant-design/icons@6.2.5", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/icons-svg": "^4.4.2", "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-0hKtoKqTjGFOndUyJLJmC9Cg6k4rEO7rLo6xmgbNJH+/ZX1C57RVals2v1j1knHl9n7Q+sBOveTvn931wLOCKw=="], + "@ant-design/icons": ["@ant-design/icons@6.3.2", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/icons-svg": "^4.5.0", "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g=="], - "@ant-design/icons-svg": ["@ant-design/icons-svg@4.4.2", "", {}, "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="], + "@ant-design/icons-svg": ["@ant-design/icons-svg@4.5.0", "", {}, "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA=="], "@ant-design/react-slick": ["@ant-design/react-slick@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "clsx": "^2.1.1", "json2mq": "^0.2.0", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg=="], "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], - "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -266,19 +194,15 @@ "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], - "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="], + "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-6re5avCNfyRMIoi3XNjbEfQM1vTeVD3JS3g/Fyegyso/eoANFM71Cyvbb66LDyYtQLMEcRFlzioywCqDo9SlLA=="], "@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="], "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], - "@codemirror/state": ["@codemirror/state@6.7.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg=="], + "@codemirror/state": ["@codemirror/state@6.7.1", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A=="], - "@codemirror/view": ["@codemirror/view@6.43.4", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-YImu23iyKfncJzT7sRy+rEqEhSc8RhOHqDxwy4WzXRKJwYm6iwf/9OJk5ctCAdZ6yi2ZqaGEvmf55fSVqMDrgg=="], - - "@croct/json": ["@croct/json@2.1.0", "", {}, "sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ=="], - - "@croct/json5-parser": ["@croct/json5-parser@0.2.2", "", { "dependencies": { "@croct/json": "^2.1.0" } }, "sha512-0NJMLrbeLbQ0eCVj3UoH/kG2QckUgOASfwmfDTjyW1xAYPyTNJXcWVT/dssJdTJd0pRchW+qF0VFWQHcxs1OVw=="], + "@codemirror/view": ["@codemirror/view@6.43.6", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA=="], "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], @@ -288,31 +212,13 @@ "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], - "@dnd-kit/sortable": ["@dnd-kit/sortable@7.0.2", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.0", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.0.7", "react": ">=16.8.0" } }, "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA=="], + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.70.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "enquirer": "^2.4.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-vC/rom87ym8HEyVdzZZS6/PYGg1Z5fmozUZ8l6cw1sYAxdL1lEyvE/JbK8cMFQoq3GsR/P1PiQRY+VXMtDN9bw=="], - - "@douyinfe/semi-animation": ["@douyinfe/semi-animation@2.99.3", "", { "dependencies": { "bezier-easing": "^2.1.0" } }, "sha512-Uva9MLF+EjC+m6eBYnX9PFZIQKLxD+iKV6ps/nX/P1FWy17DCDxIsga/cByF0PIsVRLzrSdkCsddj3XETcDw9A=="], - - "@douyinfe/semi-animation-react": ["@douyinfe/semi-animation-react@2.99.3", "", { "dependencies": { "@douyinfe/semi-animation": "2.99.3", "@douyinfe/semi-animation-styled": "2.99.3", "classnames": "^2.2.6" } }, "sha512-0iUWQRO1t838Q1VaPE7DwOnYWeAuuu98MrNnaFkbD8JncYsct2K/2A5TDfa56DwSZ5iVz53jz2En8dMi7oF8sw=="], - - "@douyinfe/semi-animation-styled": ["@douyinfe/semi-animation-styled@2.99.3", "", {}, "sha512-38/ui6SoIJFWRs2jHv1IiNV2CKHaQKhYB4WftCVXCaYYQGL24+0oQ3iLo6qUeaHEWiQK3EcK2Rt7pxtJCJxVOA=="], - - "@douyinfe/semi-foundation": ["@douyinfe/semi-foundation@2.99.3", "", { "dependencies": { "@douyinfe/semi-animation": "2.99.3", "@douyinfe/semi-json-viewer-core": "2.99.3", "@mdx-js/mdx": "^3.0.1", "async-validator": "^3.5.0", "classnames": "^2.2.6", "date-fns": "^2.29.3", "date-fns-tz": "^1.3.8", "fast-copy": "^3.0.1 ", "lodash": "^4.17.21", "lottie-web": "^5.13.0", "memoize-one": "^5.2.1", "prismjs": "^1.29.0", "remark-gfm": "^4.0.0", "scroll-into-view-if-needed": "^2.2.24" } }, "sha512-HKzrcdNGYoEZD81CKI6fj8jU2MWNrZx8HZ0NDHym+smBxSyhpoE/b0FrVo0PmLjCzbCDnySDdJ31GsK5GScmuw=="], - - "@douyinfe/semi-icons": ["@douyinfe/semi-icons@2.99.3", "", { "dependencies": { "classnames": "^2.2.6" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-Pm5H3Ua/PDumUCCsnJWwN+znVoKiyFCqag6DJy9/cuF6OOdd1+QUnvi0NHNg6+0fx/LHH088UwKFoOiZRkbaSw=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], - "@douyinfe/semi-illustrations": ["@douyinfe/semi-illustrations@2.99.3", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-z1rQPgWOV2xtZS8NkmL8JCK1DltQ8FGiL1qYlXbSHjEs1XkNYruq4W3dKv0IJEpTVLIlPsbDg4VmPAuuwLCCkQ=="], - - "@douyinfe/semi-json-viewer-core": ["@douyinfe/semi-json-viewer-core@2.99.3", "", { "dependencies": { "jsonc-parser": "^3.3.1" } }, "sha512-KEbZEyyM2qqGv9K+Yw/ZvAn4CEgcY2lQfL6a2ASEt80FlPoDAIWA7tGjpYxxM9/NcX9omNtsM/HLgDmrCjjBXQ=="], - - "@douyinfe/semi-theme-default": ["@douyinfe/semi-theme-default@2.99.3", "", {}, "sha512-r0IIjrN6vQE1bqbky7FIRi4HQ03x4ykzSIRMf4Za04BFp76IFV6CclyYyUg6cLJ6GjWCnEPMFtwTLKP+b8dAYA=="], - - "@douyinfe/semi-ui": ["@douyinfe/semi-ui@2.99.3", "", { "dependencies": { "@dnd-kit/core": "^6.0.8", "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.1", "@douyinfe/semi-animation": "2.99.3", "@douyinfe/semi-animation-react": "2.99.3", "@douyinfe/semi-foundation": "2.99.3", "@douyinfe/semi-icons": "2.99.3", "@douyinfe/semi-illustrations": "2.99.3", "@douyinfe/semi-theme-default": "2.99.3", "@tiptap/core": "^3.10.7", "@tiptap/extension-document": "^3.10.7", "@tiptap/extension-hard-break": "^3.10.7", "@tiptap/extension-image": "^3.10.7", "@tiptap/extension-mention": "^3.10.7", "@tiptap/extension-paragraph": "^3.10.7", "@tiptap/extension-text": "^3.10.7", "@tiptap/extension-text-align": "^3.10.7", "@tiptap/extension-text-style": "^3.10.7", "@tiptap/extensions": "^3.10.7", "@tiptap/pm": "^3.10.7", "@tiptap/react": "^3.10.7", "@tiptap/starter-kit": "^3.10.7", "async-validator": "^3.5.0", "classnames": "^2.2.6", "copy-text-to-clipboard": "^2.1.1", "date-fns": "^2.29.3", "date-fns-tz": "^1.3.8", "fast-copy": "^3.0.1 ", "jsonc-parser": "^3.3.1", "lodash": "^4.17.21", "prop-types": "^15.7.2", "prosemirror-state": "^1.4.3", "react-resizable": "^3.0.5", "react-window": "^1.8.2", "scroll-into-view-if-needed": "^2.2.24", "utility-types": "^3.10.0" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-6NkeijjZZWzD31omteNVLz+oZuuMKQm3nEcwLI8+44Vv+VUSJPb87WnSFSD3F6eUIt/hZp2vJbCXHWW9SbCpDw=="], - - "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], + "@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="], "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], @@ -350,27 +256,19 @@ "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - - "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], - - "@eslint/js": ["@eslint/js@8.57.0", "", {}, "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g=="], + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], - "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], - "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + "@floating-ui/react": ["@floating-ui/react@0.27.20", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw=="], - "@floating-ui/react": ["@floating-ui/react@0.27.19", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog=="], + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], - "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fontsource-variable/lora": ["@fontsource-variable/lora@5.3.0", "", {}, "sha512-2ph8KiZ8Bn141hWuC3ErtpJ+Ui8p2MJQn8TJNRSRmLmnCIOCtHfiBM9R7WLRPeUG/q6hXyHOxWQ8DvRSX+ixvQ=="], - "@fontsource-variable/lora": ["@fontsource-variable/lora@5.2.8", "", {}, "sha512-cxjTJ9BbOWIzusewR4UMBLVePvTSWV6dtNaNsCkF/oKoyA68fJGWfaYCILOOP1BObE4dmjfZ3xo6m9hdHhtYhg=="], - - "@fontsource-variable/public-sans": ["@fontsource-variable/public-sans@5.2.7", "", {}, "sha512-4mvade2J3slKkvwRkS+p8T3szet/0vhWoSnuUJTVU81Uo2pRpSZY/Y8bSLRqpSwzIPxjVmRJ53oq6JKP/l/PSg=="], + "@fontsource-variable/public-sans": ["@fontsource-variable/public-sans@5.3.0", "", {}, "sha512-AVfkmAt50BMXWpOO21FAntiJFKGX6xTc2dSL8dxtDteONe9IuRXJWGbs0EbG955vAMCq23ENeuopuW87cGWDSQ=="], "@giscus/react": ["@giscus/react@3.1.0", "", { "dependencies": { "giscus": "^1.6.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18 || ^19", "react-dom": "^16 || ^17 || ^18 || ^19" } }, "sha512-0TCO2TvL43+oOdyVVGHDItwxD1UMKP2ZYpT6gXmhFOqfAJtZxTzJ9hkn34iAF/b6YzyJ4Um89QIt9z/ajmAEeg=="], @@ -382,47 +280,9 @@ "@hugeicons/react": ["@hugeicons/react@1.1.9", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-O+lWSWjbijoAvMCxn4K2bQWCGN5+mP1y5j+X99j23mXMj+s0X25fs71T6t9YJLaBodwmZdaewD27dzS/PiboQw=="], - "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.11.14", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.2", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg=="], - - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], - - "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], - "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], - "@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="], - - "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], - - "@inquirer/checkbox": ["@inquirer/checkbox@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw=="], - - "@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], - - "@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="], - - "@inquirer/editor": ["@inquirer/editor@5.2.2", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/external-editor": "^3.0.3", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg=="], - - "@inquirer/expand": ["@inquirer/expand@5.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g=="], - - "@inquirer/external-editor": ["@inquirer/external-editor@3.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA=="], - - "@inquirer/figures": ["@inquirer/figures@2.0.7", "", {}, "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw=="], - - "@inquirer/input": ["@inquirer/input@5.1.2", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg=="], - - "@inquirer/number": ["@inquirer/number@4.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA=="], - - "@inquirer/password": ["@inquirer/password@5.1.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg=="], - - "@inquirer/prompts": ["@inquirer/prompts@8.5.2", "", { "dependencies": { "@inquirer/checkbox": "^5.2.1", "@inquirer/confirm": "^6.1.1", "@inquirer/editor": "^5.2.2", "@inquirer/expand": "^5.1.1", "@inquirer/input": "^5.1.2", "@inquirer/number": "^4.1.1", "@inquirer/password": "^5.1.1", "@inquirer/rawlist": "^5.3.1", "@inquirer/search": "^4.2.1", "@inquirer/select": "^5.2.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g=="], - - "@inquirer/rawlist": ["@inquirer/rawlist@5.3.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og=="], - - "@inquirer/search": ["@inquirer/search@4.2.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g=="], - - "@inquirer/select": ["@inquirer/select@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw=="], - - "@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="], + "@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -436,7 +296,7 @@ "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], - "@lezer/css": ["@lezer/css@1.3.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg=="], + "@lezer/css": ["@lezer/css@1.3.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q=="], "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], @@ -446,7 +306,7 @@ "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], - "@lezer/markdown": ["@lezer/markdown@1.6.4", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA=="], + "@lezer/markdown": ["@lezer/markdown@1.7.2", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ=="], "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.6.0", "", {}, "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ=="], @@ -456,27 +316,21 @@ "@lobehub/fluent-emoji": ["@lobehub/fluent-emoji@4.1.0", "", { "dependencies": { "@lobehub/emojilib": "^1.0.0", "antd-style": "^4.1.0", "emoji-regex": "^10.6.0", "es-toolkit": "^1.43.0", "lucide-react": "^0.562.0", "url-join": "^5.0.0" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-R1MB2lfUkDvB7XAQdRzY75c1dx/tB7gEvBPaEEMarzKfCJWmXm7rheS6caVzmgwAlq5sfmTbxPL+un99sp//Yw=="], - "@lobehub/icons": ["@lobehub/icons@5.10.1", "", { "dependencies": { "antd-style": "^4.1.0", "es-toolkit": "^1.45.1", "lucide-react": "^0.469.0", "polished": "^4.3.1" }, "peerDependencies": { "@lobehub/ui": "^5.0.0", "antd": "^6.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-KMaE+YqPAXuA8gcmzBFefLa9KgCqmJy9Mg3tlGedrL2coAzCQeps+aqivjejHNMnCDTPnGb+OHvX1um2kT1lQw=="], + "@lobehub/icons": ["@lobehub/icons@5.14.0", "", { "dependencies": { "antd-style": "^4.1.0", "es-toolkit": "^1.49.0", "lucide-react": "^0.469.0", "polished": "^4.3.1" }, "peerDependencies": { "@lobehub/ui": "^5.0.0", "antd": "^6.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-ZQADILVrprfGOGw6YfFl2IDbb8C+DLIHtkkdEjI0J2lP3jPS+s3qQ2t4DPjoNqhGF/fdpU7a2dEFusSe/G1d0w=="], - "@lobehub/ui": ["@lobehub/ui@5.15.6", "", { "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@base-ui/react": "1.5.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@emotion/is-prop-valid": "^1.4.0", "@floating-ui/react": "^0.27.19", "@giscus/react": "^3.1.0", "@mdx-js/mdx": "^3.1.1", "@mdx-js/react": "^3.1.1", "@pierre/diffs": "^1.1.19", "@radix-ui/react-slot": "^1.2.4", "@shikijs/core": "^4.0.2", "@shikijs/transformers": "^4.0.2", "@splinetool/runtime": "0.9.526", "ahooks": "^3.9.7", "antd-style": "^4.1.0", "chroma-js": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", "emoji-mart": "^5.6.0", "es-toolkit": "^1.46.0", "fast-deep-equal": "^3.1.3", "immer": "^11.1.4", "katex": "^0.16.45", "leva": "^0.10.1", "lucide-react": "^1.11.0", "marked": "^17.0.6", "mermaid": "^11.14.0", "motion": "^12.38.0", "numeral": "^2.0.6", "polished": "^4.3.1", "query-string": "^9.3.1", "rc-collapse": "^4.0.0", "rc-footer": "^0.6.8", "rc-image": "^7.12.0", "rc-input-number": "^9.5.0", "rc-menu": "^9.16.1", "re-resizable": "^6.11.2", "react-avatar-editor": "^15.1.0", "react-error-boundary": "^6.1.1", "react-hotkeys-hook": "^5.2.4", "react-markdown": "^10.1.0", "react-merge-refs": "^3.0.2", "react-rnd": "^10.5.3", "react-zoom-pan-pinch": "^3.7.0", "rehype-github-alerts": "^4.2.0", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-breaks": "^4.0.0", "remark-cjk-friendly": "^2.0.1", "remark-gfm": "^4.0.1", "remark-github": "^12.0.0", "remark-math": "^6.0.0", "remend": "^1.3.0", "shiki": "^4.0.2", "shiki-stream": "^0.1.4", "swr": "^2.4.1", "ts-md5": "^2.0.1", "unified": "^11.0.5", "url-join": "^5.0.0", "use-merge-value": "^1.2.0", "uuid": "^13.0.0", "virtua": "^0.49.1" }, "peerDependencies": { "@lobehub/fluent-emoji": "^4.0.0", "@lobehub/icons": "^5.0.0", "antd": "^6.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-sjx95F9viJWRuhFlhe+pN7y6/b+dv9U6ysMcO8F+sFUQNYTBfUl80UkBLclHQc2adpxdrkzEN+0g0AXeFsCC1g=="], + "@lobehub/ui": ["@lobehub/ui@5.22.3", "", { "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@base-ui/react": "1.6.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@emotion/is-prop-valid": "^1.4.0", "@floating-ui/react": "^0.27.19", "@giscus/react": "^3.1.0", "@mdx-js/mdx": "^3.1.1", "@mdx-js/react": "^3.1.1", "@pierre/diffs": "1.2.12", "@radix-ui/react-slot": "^1.3.0", "@shikijs/core": "^4.3.1", "@shikijs/stream": "^4.3.1", "@shikijs/transformers": "^4.3.1", "@splinetool/runtime": "1.12.98", "ahooks": "^3.9.7", "antd-style": "^4.1.0", "chroma-js": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.21", "emoji-mart": "^5.6.0", "es-toolkit": "^1.49.0", "fast-deep-equal": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "immer": "^11.1.11", "katex": "^0.17.0", "leva": "^0.10.1", "lucide-react": "^1.24.0", "marked": "^18.0.6", "mermaid": "^11.16.0", "motion": "^12.0.0", "numeral": "^2.0.6", "polished": "^4.3.1", "query-string": "^9.4.1", "rc-collapse": "^4.0.0", "rc-footer": "^0.6.8", "rc-image": "^7.12.0", "rc-input-number": "^9.5.0", "rc-menu": "^9.16.1", "re-resizable": "^6.11.2", "react-avatar-editor": "^15.1.0", "react-error-boundary": "^6.1.2", "react-hotkeys-hook": "^5.3.3", "react-markdown": "^10.1.0", "react-merge-refs": "^3.0.2", "react-rnd": "^10.5.3", "react-zoom-pan-pinch": "^4.0.3", "rehype-github-alerts": "^4.2.0", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-breaks": "^4.0.0", "remark-cjk-friendly": "^2.3.1", "remark-gfm": "^4.0.1", "remark-github": "^12.0.0", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "^1.3.0", "shiki": "^4.3.1", "swr": "^2.4.2", "ts-md5": "^2.0.1", "unified": "^11.0.5", "url-join": "^5.0.0", "use-merge-value": "^1.2.0", "uuid": "^14.0.1", "vfile": "^6.0.3", "virtua": "^0.49.3" }, "peerDependencies": { "@lobehub/fluent-emoji": "^4.0.0", "@lobehub/icons": "^5.0.0", "antd": "^6.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-sw773r8aXCDKacJgxtshJHLk5flpIRAHHAGImKCD3Z6USO4C5lj9Q6JK4B67c18gSOE7X2JsI8QK6Korim0dzA=="], - "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.3", "", {}, "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], - "@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], - - "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], - - "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], - - "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -602,101 +456,101 @@ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bQJdH9i4RRfw55jm7+8/xS7GzHLLTbHx4huhrrDxQJaJtbSDbsyOnODvP1ftT7EG0KFKAYO2S+q6AcioXODx8w=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.72.0", "", { "os": "android", "cpu": "arm" }, "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.74.0", "", { "os": "android", "cpu": "arm" }, "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.72.0", "", { "os": "android", "cpu": "arm64" }, "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.74.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.72.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.74.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.72.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.74.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.72.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.74.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.72.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.74.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.72.0", "", { "os": "linux", "cpu": "arm" }, "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.74.0", "", { "os": "linux", "cpu": "arm" }, "sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.72.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.74.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.72.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.74.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.72.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.74.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.72.0", "", { "os": "linux", "cpu": "none" }, "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.74.0", "", { "os": "linux", "cpu": "none" }, "sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.72.0", "", { "os": "linux", "cpu": "none" }, "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.74.0", "", { "os": "linux", "cpu": "none" }, "sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.72.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.74.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.72.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.74.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.72.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.74.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.72.0", "", { "os": "none", "cpu": "arm64" }, "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.74.0", "", { "os": "none", "cpu": "arm64" }, "sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.72.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.74.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.72.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.74.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.72.0", "", { "os": "win32", "cpu": "x64" }, "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA=="], - "@pierre/diffs": ["@pierre/diffs@1.2.5", "", { "dependencies": { "@pierre/theme": "1.0.3", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-uYOz3Kfs5ED0qY0VraUXzylsEKvZPTVdexboM3QKPx/qBZmTT9F3lKAFuPpY5aIrV04sdHtoFCKStyzEu99U2A=="], + "@pierre/diffs": ["@pierre/diffs@1.2.12", "", { "dependencies": { "@pierre/theme": "1.1.0", "@pierre/theming": "0.0.2", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-pY/gmgWL03WnagqCyCnBi3QtRXUv4hCIY6FYqd5b1ZGaoI6a4Bsji8j+yRl2RfzPh/8Hf19rCl1GE80G6a1cLQ=="], - "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], + "@pierre/theme": ["@pierre/theme@1.1.0", "", {}, "sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ=="], - "@primer/octicons": ["@primer/octicons@19.28.0", "", { "dependencies": { "object-assign": "^4.1.1" } }, "sha512-FCpW9ZXI9U9h7wjYSXFQK4Zyp1Roc/kF8nymak4bYccWaWoUixbnIr4u8UYiRoPRSglm+23TZEyUZHrgNql9Jw=="], + "@pierre/theming": ["@pierre/theming@0.0.2", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw=="], - "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@primer/octicons": ["@primer/octicons@19.29.2", "", { "dependencies": { "object-assign": "^4.1.1" } }, "sha512-n7zuEgzTz70m5dmBqpYuE8zpeLKxgf73OeDudQWByNzIwWLb0I/UlxqdCLkk6Qb338O7em0aNuhVfExNBVCfTQ=="], - "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.6", "", {}, "sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw=="], - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.12", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ltXCE0glRomMZ9+u10d9o1Go+edqa1aLxufH59JRNNM3Yz1uvaeNWSaS1HeVh1X64agtdBG5JA1W1I6ySqWiwA=="], - "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="], - "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + "@radix-ui/react-context": ["@radix-ui/react-context@1.2.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg=="], - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.6", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.16", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.13", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.14", "@radix-ui/react-presence": "1.1.8", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.4", "@radix-ui/react-use-layout-effect": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-cngVJcvK0yMvR7wICJpv+1uW3Qw4T7QM5sdbb+oE/lxOdTdvF00oaRpWUjVgmjyXe3J+xh7eZyXZlVF3g2g59g=="], - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.6", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t45h68IjFx0ccBnPJqk0X6ecv69LkCFWd6DNCFQX56mUnVEXZbNOLCH/u9fHlAjFZ1RrFdl8/m4zev7B7NyhXQ=="], - "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="], - "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.13", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg=="], - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="], - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.4", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PXnCa3XgTQk0FegMctxgqJXtFLZe4IFJdbUkB7jKSCKEpb6utEO4S9Vog/pkyCfEPdzM331gvE4xpztmBAfMng=="], - "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.14", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw=="], - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.8", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0hhyrQdXMaATgq4ammLG9+iPqsXxzZkgTSIxdrJHdfLnXO4Uo5L7BoO3/Xf0AEaettadGZWGGJMw6ujzQvIpGA=="], - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.7", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ=="], - "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.6", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.16", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.4", "@radix-ui/react-portal": "1.1.14", "@radix-ui/react-presence": "1.1.8", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.4", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.8" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-56XPNYGMnGBcPyiBTaEXB7IGPybbsdNkFgSv90SCrHkXnu2Av1HhsyZMegzXlTu/QHA3V6/l22GZCv9iEoiqmQ=="], - "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], - "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.6", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cx2DixxmSfjCcEoRvDvy1NLd6SWK94XFcEEOZUcharUlXbmahFQGKCfwdKZL2ub34iIwOPOEFVF80xb+yfLYiA=="], - "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="], - "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], - "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.2", "", { "dependencies": { "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw=="], - "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="], - "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FjsQEpkNBJJYiPSat6jh2LGKLPX2jAoDVS3AZSBNX3cOUoEGhw/f+z2FCY8Cf1NkoYIbytJ1f4mlWPQpR+MjVg=="], - "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="], - "@rc-component/async-validator": ["@rc-component/async-validator@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA=="], + "@rc-component/async-validator": ["@rc-component/async-validator@6.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA=="], - "@rc-component/cascader": ["@rc-component/cascader@1.15.0", "", { "dependencies": { "@rc-component/select": "~1.6.0", "@rc-component/tree": "~1.3.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ZzpMtwFCRo3fbXHuDnncARJMZQjdqA2w7aDuPofNQt+aDx39st1hgfIpEwTBLhe2Hqsvs/zOr8RTtgxTkCPySw=="], + "@rc-component/cascader": ["@rc-component/cascader@1.17.0", "", { "dependencies": { "@rc-component/select": "~1.8.0", "@rc-component/tree": "~1.3.2", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-3cVNG0zrQF1PoXq262L3wGCU+/YLEC1mGSVHDl577dQmA0ZKkXFbY6nwyXo+beCcM7buo49t24jkr+QZdL7O8w=="], "@rc-component/checkbox": ["@rc-component/checkbox@2.0.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ=="], @@ -704,15 +558,15 @@ "@rc-component/color-picker": ["@rc-component/color-picker@3.1.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.1", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg=="], - "@rc-component/context": ["@rc-component/context@2.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw=="], + "@rc-component/context": ["@rc-component/context@2.0.2", "", { "dependencies": { "@rc-component/util": "^1.11.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA=="], - "@rc-component/dialog": ["@rc-component/dialog@1.9.0", "", { "dependencies": { "@rc-component/motion": "^1.1.3", "@rc-component/portal": "^2.1.0", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-zbAAogkg4kkKum79sLE6M+vq1jSAW25zdkafrahgcTP9t9S//SD634Znd1A4c8F2Gc12ZKnehGLsVaaOvZzD2A=="], + "@rc-component/dialog": ["@rc-component/dialog@1.10.0", "", { "dependencies": { "@rc-component/motion": "^1.3.3", "@rc-component/portal": "^2.1.0", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ=="], "@rc-component/drawer": ["@rc-component/drawer@1.4.2", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.1.3", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q=="], - "@rc-component/dropdown": ["@rc-component/dropdown@1.0.2", "", { "dependencies": { "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg=="], + "@rc-component/dropdown": ["@rc-component/dropdown@1.0.3", "", { "dependencies": { "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g=="], - "@rc-component/form": ["@rc-component/form@1.8.2", "", { "dependencies": { "@rc-component/async-validator": "^5.1.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ZidCvOLmM9Xr+3vzk4UAoR7Aj1W/5IHyrzlBB7sNkygpTeRVrohQSo4TN7W/nARTH+nt8zSAPsn4BEl4zLEO2g=="], + "@rc-component/form": ["@rc-component/form@1.8.5", "", { "dependencies": { "@rc-component/async-validator": "^6.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-d24EYtvUOBhxEtSd/EqIu9DaMuqrWF2IRIvAFCTM6NQ/GJIYNr8DvEpUSUlv2uPxEJ0ZPwYQ+wwlGIAaiHvdrw=="], "@rc-component/image": ["@rc-component/image@1.9.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/portal": "^2.1.2", "@rc-component/util": "^1.10.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ=="], @@ -720,13 +574,13 @@ "@rc-component/input-number": ["@rc-component/input-number@1.6.2", "", { "dependencies": { "@rc-component/mini-decimal": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w=="], - "@rc-component/mentions": ["@rc-component/mentions@1.9.0", "", { "dependencies": { "@rc-component/input": "~1.3.0", "@rc-component/menu": "~1.3.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WUwfFKDSOF5S9UPsNsXcLYtzjTxBGsftTXWRbZuxX6BYrsySISTnujfJNgaaQ6qVzaCDJ35QUkZKvsYxip1C5g=="], + "@rc-component/mentions": ["@rc-component/mentions@1.10.0", "", { "dependencies": { "@rc-component/input": "~1.3.0", "@rc-component/menu": "~1.4.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-CI1njYUVY0NjHtLhNoVmXlJyy568Sfep9Wsak6vmGjtT6uazx98djGYlCXz2xkHhEm73g91Y3MTvzUyE5avI7w=="], - "@rc-component/menu": ["@rc-component/menu@1.3.1", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-pSZl9nBPgKgxN0aaW7NilIBEwWsc+43S+ulGdWAg9afak96dNOGWsGx0DLLBB1VQsAJvo6bQMTDzXoPlEHsBEw=="], + "@rc-component/menu": ["@rc-component/menu@1.4.1", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3GsVRoQ4cnF/AoIQ4P+Z1haBfgfBPQfLT1RJY3Nu4DzOnheTslfCiGSPj7bv/cLj5sW5pHqN25dDXGP3JELAlQ=="], - "@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw=="], + "@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.4", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w=="], - "@rc-component/motion": ["@rc-component/motion@1.3.2", "", { "dependencies": { "@rc-component/util": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-itfd+GztzJYAb04Z4RkEub1TbJAfZc2Iuy8p44U44xD1F5+fNYFKI3897ijlbIyfvXkTmMm+KGcjkQQGMHywEQ=="], + "@rc-component/motion": ["@rc-component/motion@1.3.3", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A=="], "@rc-component/mutate-observer": ["@rc-component/mutate-observer@2.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w=="], @@ -734,15 +588,15 @@ "@rc-component/overflow": ["@rc-component/overflow@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA=="], - "@rc-component/pagination": ["@rc-component/pagination@1.2.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw=="], + "@rc-component/pagination": ["@rc-component/pagination@1.4.0", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw=="], - "@rc-component/picker": ["@rc-component/picker@1.10.0", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/trigger": "^3.6.15", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "dayjs", "luxon", "moment"] }, "sha512-vVOXP2RVWozwpERGUFAehVH1Jz6o/uRrAb9qSZm1LC+iJs8rvEwFo1bzz2jlOYV+uWwu0dIuG86tnDui14Ea0w=="], + "@rc-component/picker": ["@rc-component/picker@1.11.0", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/trigger": "^3.6.15", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "dayjs", "luxon", "moment"] }, "sha512-6qXGKtoJvO8sUd17m5cyNEbEJub0zflCHnaZTBBmj63DPRZYc0WEHN8rp6hFSl+yMCJS/dJY5G+1fQ8bLCuD7A=="], "@rc-component/portal": ["@rc-component/portal@1.1.2", "", { "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg=="], "@rc-component/progress": ["@rc-component/progress@1.0.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ=="], - "@rc-component/qrcode": ["@rc-component/qrcode@1.1.1", "", { "dependencies": { "@babel/runtime": "^7.24.7" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA=="], + "@rc-component/qrcode": ["@rc-component/qrcode@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.7" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw=="], "@rc-component/rate": ["@rc-component/rate@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw=="], @@ -750,17 +604,17 @@ "@rc-component/segmented": ["@rc-component/segmented@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg=="], - "@rc-component/select": ["@rc-component/select@1.6.15", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-SyVCWnqxCQZZcQvQJ/CxSjx2bGma6ds/HtnpkIfZVnt6RoEgbqUmHgD6vrzNarNXwbLXerwVzWwq8F3d1sst7g=="], + "@rc-component/select": ["@rc-component/select@1.8.2", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-HQ9zuYqjfZTlcEMWlU1GAPBajd2OHIMVHyjZSGVTCVARwkfCgvXZMTEn0cduy3L+ejAKkaZluOQvxovZoaJaQw=="], - "@rc-component/slider": ["@rc-component/slider@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g=="], + "@rc-component/slider": ["@rc-component/slider@1.1.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig=="], "@rc-component/steps": ["@rc-component/steps@1.2.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw=="], "@rc-component/switch": ["@rc-component/switch@1.0.3", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw=="], - "@rc-component/table": ["@rc-component/table@1.10.2", "", { "dependencies": { "@rc-component/context": "^2.0.1", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-b3PjqB9Gp25p5t/zq+9QrbXbodkptT8/zvLmwgd2FNPUUtaYyDnQqfxeD5a7ao8E8lpinLHsi2u2vdfPhyNvAw=="], + "@rc-component/table": ["@rc-component/table@1.10.4", "", { "dependencies": { "@rc-component/context": "^2.0.1", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-HwoTnrwc29zeoXkXGhWqzJh8FIibGUxi1jM4LtoSzmR9d5Vv5osUQpZxnXKBP8iOCvyD6BQzZm1nXJRcnrxpAg=="], - "@rc-component/tabs": ["@rc-component/tabs@1.9.1", "", { "dependencies": { "@rc-component/dropdown": "~1.0.0", "@rc-component/menu": "~1.3.0", "@rc-component/motion": "^1.1.3", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-6mY08Fce6aNOHuGsxbzT+f2ekgL9mg1cGGHkittMlVGymjGg+kGupu5v90sRxcUd/paRU9jclLLXtF/PkK1FUA=="], + "@rc-component/tabs": ["@rc-component/tabs@1.11.0", "", { "dependencies": { "@rc-component/dropdown": "~1.0.0", "@rc-component/menu": "~1.4.0", "@rc-component/motion": "^1.1.3", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-hA/drZYOVa/MMIb4M2fWf3yaTyTG4qVuIABmghvEhyfw2nBob5VTH69lMCDjSVKmgODjO6nWlCV+gVn3xBrj5Q=="], "@rc-component/tooltip": ["@rc-component/tooltip@1.4.0", "", { "dependencies": { "@rc-component/trigger": "^3.7.1", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg=="], @@ -768,20 +622,18 @@ "@rc-component/tree": ["@rc-component/tree@1.3.2", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA=="], - "@rc-component/tree-select": ["@rc-component/tree-select@1.9.0", "", { "dependencies": { "@rc-component/select": "~1.6.0", "@rc-component/tree": "~1.3.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-GXcFe15a+trUl1/J3OHWQhsVWFpwFpGFK2cqYWZ1sK22Zs3KZTvMwDpzr75PIo1s6QVioVxpE/pRwRopkeDQ6w=="], + "@rc-component/tree-select": ["@rc-component/tree-select@1.11.0", "", { "dependencies": { "@rc-component/select": "~1.8.0", "@rc-component/tree": "~1.3.2", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-EhS0X0wtUhBfK4S5TlpSY3MR9ndPMGgujtt1PJW3Ej+ToAlnS/6ohYURtCoXBYGqazUwHmgQGVUDsfpVwhWPkg=="], - "@rc-component/trigger": ["@rc-component/trigger@3.9.1", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.2.0", "@rc-component/resize-observer": "^1.1.1", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-LNsYvz60mrLJ/kRvKcHE7boUvcQfVMCfRqZ71x3Fo9AOiZ1KKIEqkzMA8DNvz2V3Bcvir/vwQNn7JF1NPODQ7Q=="], + "@rc-component/trigger": ["@rc-component/trigger@3.10.1", "", { "dependencies": { "@rc-component/motion": "^1.3.3", "@rc-component/portal": "^2.2.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw=="], "@rc-component/upload": ["@rc-component/upload@1.1.1", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA=="], - "@rc-component/util": ["@rc-component/util@1.11.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg=="], + "@rc-component/util": ["@rc-component/util@1.12.0", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^19.2.7" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ=="], - "@rc-component/virtual-list": ["@rc-component/virtual-list@1.2.0", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-iavRm1Jo4GDbASQwdGa7jFyk93RvSOo9xHyBT4QL1pgFJj/Fdf1G+3RErH7/7BmAMvx2AkF62mjGYxDbXsK9TQ=="], + "@rc-component/virtual-list": ["@rc-component/virtual-list@1.4.0", "", { "dependencies": { "@babel/runtime": "^8.0.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-qoyNStkTJQDezPjBibGA5HNxS9NiKJvemD1bLp7qfyxDlwy7ofPLUP0ZqJ47hR8AKcFaizd0AP/7QWLTLpudKQ=="], "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], - "@remix-run/router": ["@remix-run/router@1.23.3", "", {}, "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q=="], - "@resvg/resvg-js": ["@resvg/resvg-js@2.4.1", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.4.1", "@resvg/resvg-js-android-arm64": "2.4.1", "@resvg/resvg-js-darwin-arm64": "2.4.1", "@resvg/resvg-js-darwin-x64": "2.4.1", "@resvg/resvg-js-linux-arm-gnueabihf": "2.4.1", "@resvg/resvg-js-linux-arm64-gnu": "2.4.1", "@resvg/resvg-js-linux-arm64-musl": "2.4.1", "@resvg/resvg-js-linux-x64-gnu": "2.4.1", "@resvg/resvg-js-linux-x64-musl": "2.4.1", "@resvg/resvg-js-win32-arm64-msvc": "2.4.1", "@resvg/resvg-js-win32-ia32-msvc": "2.4.1", "@resvg/resvg-js-win32-x64-msvc": "2.4.1" } }, "sha512-wTOf1zerZX8qYcMmLZw3czR4paI4hXqPjShNwJRh5DeHxvgffUS5KM7XwxtbIheUW6LVYT5fhT2AJiP6mU7U4A=="], "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.4.1", "", { "os": "android", "cpu": "arm" }, "sha512-AA6f7hS0FAPpvQMhBCf6f1oD1LdlqNXKCxAAPpKh6tR11kqV0YIB9zOlIYgITM14mq2YooLFl6XIbbvmY+jwUw=="], @@ -808,69 +660,67 @@ "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-vY4kTLH2S3bP+puU5x7hlAxHv+ulFgcK6Zn3efKSr0M0KnZ9A3qeAjZteIpkowEFfUeMPNg2dvvoFRJA9zqxSw=="], - "@rsbuild/core": ["@rsbuild/core@2.1.4", "", { "dependencies": { "@rspack/core": "~2.1.2", "@swc/helpers": "^0.5.23" }, "peerDependencies": { "core-js": ">= 3.0.0" }, "optionalPeers": ["core-js"], "bin": { "rsbuild": "./bin/rsbuild.js" } }, "sha512-kdubx/qB6tXduCdqaW78OULLZJ3ludpuA4mOkDko18THsI1rUy9U34DsaDSnotE8GAvTeme+FX9MnqLEUlg8kQ=="], + "@rsbuild/core": ["@rsbuild/core@2.1.6", "", { "dependencies": { "@rspack/core": "~2.1.4", "@swc/helpers": "^0.5.23" }, "peerDependencies": { "core-js": ">= 3.0.0" }, "optionalPeers": ["core-js"], "bin": { "rsbuild": "./bin/rsbuild.js" } }, "sha512-w2WxblstOgHnDElkqJZVO/jM/EqPaEhg7zqQhON3Xu3Mj9FlVOJx+SgimOtghFzxLt8x7atX4oMUtMTNSZGf0Q=="], "@rsbuild/plugin-react": ["@rsbuild/plugin-react@2.1.0", "", { "dependencies": { "@rspack/plugin-react-refresh": "^2.0.2", "react-refresh": "^0.18.0" }, "peerDependencies": { "@rsbuild/core": "^2.0.0" }, "optionalPeers": ["@rsbuild/core"] }, "sha512-RQTIAWB/CwPjoWt9iAl+8HixeQVgZ7kEIBrWPCixfITyHdiD84h0YpUTpEUuz6kGHw1KXT9mHZ3Rwy6WG7aRDA=="], "@rsbuild/plugin-tailwindcss": ["@rsbuild/plugin-tailwindcss@2.0.3", "", { "dependencies": { "@tailwindcss/webpack": "^4.3.1" }, "peerDependencies": { "@rsbuild/core": "^2.0.0" }, "optionalPeers": ["@rsbuild/core"] }, "sha512-sl7mN0fyoP0W+zEyFR2xEMVAS2cZPlOAqsXwI8Ei9tUSvV7V4G8Xw2WBVCOQ/B4KEFfJ6H1+TEuPlAzHLEfc4g=="], - "@rspack/binding": ["@rspack/binding@2.1.2", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "2.1.2", "@rspack/binding-darwin-x64": "2.1.2", "@rspack/binding-linux-arm64-gnu": "2.1.2", "@rspack/binding-linux-arm64-musl": "2.1.2", "@rspack/binding-linux-riscv64-gnu": "2.1.2", "@rspack/binding-linux-riscv64-musl": "2.1.2", "@rspack/binding-linux-x64-gnu": "2.1.2", "@rspack/binding-linux-x64-musl": "2.1.2", "@rspack/binding-wasm32-wasi": "2.1.2", "@rspack/binding-win32-arm64-msvc": "2.1.2", "@rspack/binding-win32-ia32-msvc": "2.1.2", "@rspack/binding-win32-x64-msvc": "2.1.2" } }, "sha512-/mFcRSUW7Pl19KeaBIujJvZYNJQu0wD5D3aa5h+Qcph26v7nmLYlX7eajIHGi8tt2qTZX1lXifw2KLIXKwYaRQ=="], + "@rspack/binding": ["@rspack/binding@2.1.4", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "2.1.4", "@rspack/binding-darwin-x64": "2.1.4", "@rspack/binding-linux-arm64-gnu": "2.1.4", "@rspack/binding-linux-arm64-musl": "2.1.4", "@rspack/binding-linux-riscv64-gnu": "2.1.4", "@rspack/binding-linux-riscv64-musl": "2.1.4", "@rspack/binding-linux-x64-gnu": "2.1.4", "@rspack/binding-linux-x64-musl": "2.1.4", "@rspack/binding-wasm32-wasi": "2.1.4", "@rspack/binding-win32-arm64-msvc": "2.1.4", "@rspack/binding-win32-ia32-msvc": "2.1.4", "@rspack/binding-win32-x64-msvc": "2.1.4" } }, "sha512-iye4BaTYtTt0qa39avWwEsUobVxFNhQHr6B8reFeKU7sdaZsC9LUoDR8JAt5gOnbnzFMPdKgrdU/VzkC6rXbIg=="], - "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@2.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IYcxareUOYJZz+uNMSIwn+iDRiVyjZNOjoxO/zL4OFaPK8Ncrw0ka/9DqL9Gd7OpnAXN1zK3uS8yD0O1yIYI3Q=="], + "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@2.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3Xcs01iw48F4WeE4SHga6bCNb/UEFvtQX4P4eMIaJfGPjTQuxfabGE8yCPm9e3tpLZ5uo+IBnJ6nh5r6tIDOXQ=="], - "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@2.1.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-aoifkILvx/XEHyvg8yW57xu95nx7f9f/3ah1+RguHSNKcJMcoCep9VX1Ct1N0ftqg8MC0JUObc7xWL5W14hmjA=="], + "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@2.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-bz/AsCplLs+3fXULPQU9d4r8H4PdeljgHFyItvVIrA/NKZqzQ8sX0topf/zJVZAOtPH7GNnrKjq0/F0U2DHikQ=="], - "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@2.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-My4m40tyJSgiCEf3bB2KIEX710q3nZg99LIjy+8Zxgi3oZTkg1bFmFRusFU5U4eN5408zfSqDDGvjDE3Yv7o4w=="], + "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-x0HQTLU1MusCtNamuXxf3ayEPkvh9uuaq4wVyBqveRkn4FznSOoHUsxTAKMnjGARX+vdLV/y/SwWJRDp2RI4zw=="], - "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@2.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-yt+GGWUH7WPE8K97cRc8OpZhH7Pbj1vU+lkvKbDtF/rR8X9a/bJsA/nBqyUV2oBKOVbrp5I8rFZlnDskMqgvKw=="], + "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-SEYCQD9UflKJMkYGnG5nt2gcsqdkgJsQmryBC/jxo+bOuY9gUSReU99FqCt7WRQrsHLbGeAFeTWs1QzItWG/Bw=="], - "@rspack/binding-linux-riscv64-gnu": ["@rspack/binding-linux-riscv64-gnu@2.1.2", "", { "os": "linux", "cpu": "none" }, "sha512-uys8Jyw8Z3ralvICbN/L/nZfy5qELIwpOY72rhIqhoDYwFcL4fmMaY7WsvUcJOjCB2rqOcWPaWKuF2oPvo9iDQ=="], + "@rspack/binding-linux-riscv64-gnu": ["@rspack/binding-linux-riscv64-gnu@2.1.4", "", { "os": "linux", "cpu": "none" }, "sha512-jYtQKtnDRaVfyasvTGY04Z7m+xDWZYVwAIEOB4hP7czM7FVLOMgHlMlvw/EgF0DNHrBthqbPfBIS2tP50CzpEA=="], - "@rspack/binding-linux-riscv64-musl": ["@rspack/binding-linux-riscv64-musl@2.1.2", "", { "os": "linux", "cpu": "none" }, "sha512-JYNVQwqCaRGQWvjHQYzZkIzQiwllMaJwh4Rdu3ww6W2OJcJUqT08sL1pkOtU0iCxT4VUYiRRcp93VGTGpHr8fg=="], + "@rspack/binding-linux-riscv64-musl": ["@rspack/binding-linux-riscv64-musl@2.1.4", "", { "os": "linux", "cpu": "none" }, "sha512-ZgxKjQAm9pidq2kChQO2PqKI9OQpLuGD7iPBuyT0gQg3m1+7vvbbkArLBPzJ12CQHVZvqua7n/QGx8pQjJI2Zw=="], - "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@2.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-KDoPy0Msf/JLhxgPPrJQzZeB4Qpqd32em8AP5lSW2s6jR5I35dHgAe9xc2A++EQtnSrU4GTn6DBvFC7q84SihQ=="], + "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-C53B3e6M4yzlYn4hDxR9cHZV+HqkfFGR0zuhH8QCfdBfN5KyGsmXmujiFU85ANEvBgf9CF8VreBQeJ20lyto/g=="], - "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@2.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-66hWmIGvn4zCKAYXJE9Bp5SNSLYnLFq2Ke/efE+ZtWy43Dd5vk9AAOmThVGBwdwmIxmGtHGCp+cAuS4G0wu0TA=="], + "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-UaeG3FRo7e5RameQvWRNQ6KeXF29LEk67U/ohb9tF4U38mKH1OGqhmwCf5yLkqiOSgOi7Ff3ireOZD83Fso1iw=="], - "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@2.1.2", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "1.1.6" }, "cpu": "none" }, "sha512-EB4SqH8DW/E/OmqssNQvnIVGQiVUyYNlA/pcc6Ia4MlTNwu6eNDppcNLrToH+kSZpL4CpHSFfSM3eIsSuar2Rw=="], + "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@2.1.4", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "1.1.6" }, "cpu": "none" }, "sha512-0P1WZEfu7JOPzD/jQfk9U/6gnRFc7RpvYCQaYZVVYZNJ2gU6O0/yLegRGKNK/2L0zjYwiD0ynhOIBVUUERf5Pw=="], - "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@2.1.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-T6Fs/g32MRja/UpCq4AdyPRj8tA0cOkcEa4PrAcn/ztUgK8b/qMVxj5mhMI+n7k+kHZQnpeB1Q4HqdSJi6OocA=="], + "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@2.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-+aStQipk1EakLRPfD+/aQbtmTXfxqSWetcRRDWV3cAsD4ebv1tF8FLKYY1PCaFpQbX1FxzCBGF0KHxkAsi9cxA=="], - "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@2.1.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-OtxkFVz14mVL4QK8QriSELn9B6PaYGHw1jGJwVDEzpu2ZxSHCTQPz9dVE1ekYtREEqZUkRU7Fp7VfhJSmjTt2Q=="], + "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@2.1.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-7nyrLRQ07j6i6omZuwiwwTI6rpjdy/Su3niLDAG7WsLL21u3/UQQrw6Fvm8SH3XYteghoHLSM3dhgaisuUhdJg=="], - "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@2.1.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Am+nx9fLF3nzgD/K05Bs1Bb+WO8SFLWAYRbXkymaL1r+RQxjRj7jd5ap2PhGOCcfaNA4yVWkAFvmFP92eRu7bQ=="], + "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@2.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Z4je7JBaDpO9vvNMgCxlycycRJq+GQwwuXSXagW2xvvGM10Ij63YVtIehSMG9xaW8PED41oc9nWndoEsiD60pA=="], - "@rspack/core": ["@rspack/core@2.1.2", "", { "dependencies": { "@rspack/binding": "2.1.2" }, "peerDependencies": { "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", "@swc/helpers": "^0.5.23" }, "optionalPeers": ["@module-federation/runtime-tools", "@swc/helpers"] }, "sha512-crpNQKhHfnzrIl4Sa4fjH30Ho5aAPgyqpmJZ41SkUFOzyKHdZKYfE5LF3CMh7MiFQFPPxiiKf5BcpxmtZZx4MQ=="], + "@rspack/core": ["@rspack/core@2.1.4", "", { "dependencies": { "@rspack/binding": "2.1.4" }, "peerDependencies": { "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", "@swc/helpers": "^0.5.23" }, "optionalPeers": ["@module-federation/runtime-tools", "@swc/helpers"] }, "sha512-lpJgtr+JAXuDAMBJfRJ1LHyWVuYJyhZu6L6aj9t4lipUU03qwakHnzn3vwSCr68PsVvVPzR6NbJE1gJienSV0g=="], "@rspack/plugin-react-refresh": ["@rspack/plugin-react-refresh@2.0.2", "", { "peerDependencies": { "@rspack/core": "^2.0.0", "react-refresh": ">=0.10.0 <1.0.0" }, "optionalPeers": ["@rspack/core"] }, "sha512-dGNZiCxQxgAUI9sah7gd8u+O7OJZRCmqtEJNDOd8xW5RqcieC86F7p5qcShyw6onH5pKf57evpr2VjGbaFGkZg=="], "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], - "@shikijs/core": ["@shikijs/core@4.3.0", "", { "dependencies": { "@shikijs/primitive": "4.3.0", "@shikijs/types": "4.3.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ=="], + "@shikijs/core": ["@shikijs/core@4.3.1", "", { "dependencies": { "@shikijs/primitive": "4.3.1", "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.3.0", "", { "dependencies": { "@shikijs/types": "4.3.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-hTv/KiFf2tpiqlACPiztGGurEARWIutB8YUhcrA1pUC7VzzwKO+g5crUocrLztrZ5ro5Z4hbXg7bYclETn3gSQ=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.3.0", "", { "dependencies": { "@shikijs/types": "4.3.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1vMdN3gHfnKfLYwecUI2ITJI4RhHt96xEaJumVn7Heb0IlJ8WQMIH0Voak+2j22BpSNKdnOfB/pCTPnPm2gq7A=="], + "@shikijs/langs": ["@shikijs/langs@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1" } }, "sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ=="], - "@shikijs/langs": ["@shikijs/langs@4.3.0", "", { "dependencies": { "@shikijs/types": "4.3.0" } }, "sha512-rnlqFbBRSys9bT4gl/5rw9RnS0W/I84ZldXPkO7cvlEMoV85TyF/aU01N7/NbSR776RNLjrJKjfFUXJR6wN1Cg=="], + "@shikijs/primitive": ["@shikijs/primitive@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A=="], - "@shikijs/primitive": ["@shikijs/primitive@4.3.0", "", { "dependencies": { "@shikijs/types": "4.3.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-CPkz64PTa5diRW1ggzMZH9VM/du4RNChYgVtgqrFcgruvIybmCvySv8GkiHSczUHXYuuR8TdKEwFx+UnZMpgdg=="], + "@shikijs/stream": ["@shikijs/stream@4.3.1", "", { "dependencies": { "@shikijs/core": "4.3.1" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "svelte": "^5.0.0-0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "svelte", "vue"] }, "sha512-QsZDisEVgtvnEgLftu/Ng5JkZlPn37AJoJQY330RnFoNcRtszr0JV3ldRLjIpgvl+qPRKF4t6h1P7FQhjQj6Sw=="], - "@shikijs/themes": ["@shikijs/themes@4.3.0", "", { "dependencies": { "@shikijs/types": "4.3.0" } }, "sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ=="], + "@shikijs/themes": ["@shikijs/themes@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1" } }, "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA=="], - "@shikijs/transformers": ["@shikijs/transformers@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/types": "4.1.0" } }, "sha512-YbuOcAA3kwqKDU9YSt00dtFLrY5lBXjKU3dWaMATyEyPSqBm9Jqblk/uVICxz7lcjwAHzYaEvIiMWX3mTpogkA=="], + "@shikijs/transformers": ["@shikijs/transformers@4.3.1", "", { "dependencies": { "@shikijs/core": "4.3.1", "@shikijs/types": "4.3.1" } }, "sha512-z6ir0bGDgWcF2FduktEfPgIsdOtIlDiLAjFBgBzE42Q9xHbkkIXZtORHzlLVB71iZP9elEcqKg6keajvOUwE2A=="], - "@shikijs/types": ["@shikijs/types@4.3.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ=="], + "@shikijs/types": ["@shikijs/types@4.3.1", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], - "@so1ve/prettier-config": ["@so1ve/prettier-config@3.26.0", "", { "dependencies": { "@so1ve/prettier-plugin-toml": "3.26.0", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-jsdoc": "^1.8.0" }, "peerDependencies": { "prettier": "^3.7.4" } }, "sha512-DWiWczVdwbolZy/BKlFqsWWLkxarGAhtcyoS7K2u6BUXBFZg6V+87laCvs/UC/kHiucx/IvZXdf3CQtZfoYxxw=="], - - "@so1ve/prettier-plugin-toml": ["@so1ve/prettier-plugin-toml@3.26.0", "", { "peerDependencies": { "prettier": "^3.7.4" } }, "sha512-iFJpSndNdIMnzNKbdXXRqyA2rrLsVqePhX3m+ongWip3BFxmRpfW9pon2FTm7qsZrslu39wQ7wPvIuxbmXZe2g=="], - - "@splinetool/runtime": ["@splinetool/runtime@0.9.526", "", { "dependencies": { "on-change": "^4.0.0", "semver-compare": "^1.0.0" } }, "sha512-qznHbXA5aKwDbCgESAothCNm1IeEZcmNWG145p5aXj4w5uoqR1TZ9qkTHTKLTsUbHeitCwdhzmRqan1kxboLgQ=="], + "@splinetool/runtime": ["@splinetool/runtime@1.12.98", "", { "dependencies": { "on-change": "4.0.0", "semver-compare": "1.0.0" } }, "sha512-UWZH/+4XtNgMMgmDiWhZS55WOwUDGuTj4uH6fiZV6Jol3d3v3z1RRBObspx8GMio0HajOYGg7FJ8TZdyBdpW4A=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -878,67 +728,37 @@ "@stitches/react": ["@stitches/react@1.2.8", "", { "peerDependencies": { "react": ">= 16.3.0" } }, "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA=="], - "@swc/core": ["@swc/core@1.15.40", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.26" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.40", "@swc/core-darwin-x64": "1.15.40", "@swc/core-linux-arm-gnueabihf": "1.15.40", "@swc/core-linux-arm64-gnu": "1.15.40", "@swc/core-linux-arm64-musl": "1.15.40", "@swc/core-linux-ppc64-gnu": "1.15.40", "@swc/core-linux-s390x-gnu": "1.15.40", "@swc/core-linux-x64-gnu": "1.15.40", "@swc/core-linux-x64-musl": "1.15.40", "@swc/core-win32-arm64-msvc": "1.15.40", "@swc/core-win32-ia32-msvc": "1.15.40", "@swc/core-win32-x64-msvc": "1.15.40" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg=="], - - "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.40", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ=="], - - "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.40", "", { "os": "darwin", "cpu": "x64" }, "sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA=="], - - "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.40", "", { "os": "linux", "cpu": "arm" }, "sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA=="], - - "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.40", "", { "os": "linux", "cpu": "arm64" }, "sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ=="], - - "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.40", "", { "os": "linux", "cpu": "arm64" }, "sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ=="], - - "@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.15.40", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA=="], - - "@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.15.40", "", { "os": "linux", "cpu": "s390x" }, "sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg=="], - - "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.40", "", { "os": "linux", "cpu": "x64" }, "sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg=="], - - "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.40", "", { "os": "linux", "cpu": "x64" }, "sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA=="], - - "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.40", "", { "os": "win32", "cpu": "arm64" }, "sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A=="], - - "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.40", "", { "os": "win32", "cpu": "ia32" }, "sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw=="], - - "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.40", "", { "os": "win32", "cpu": "x64" }, "sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w=="], - - "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], - "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], - "@swc/types": ["@swc/types@0.1.26", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], - "@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.1", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA=="], - - "@tailwindcss/webpack": ["@tailwindcss/webpack@4.3.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "@rspack/core": "^1.0.0 || ^2.0.0", "webpack": "^5.0.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-HM33EjYPbkMlBGcYoVA/pk/hML3wAn2JOnjF79eDWVLuktOhRczDwWsSBQBOWV6LLBVAgGZS/pZ519OHUF8vKg=="], + "@tailwindcss/webpack": ["@tailwindcss/webpack@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "@rspack/core": "^1.0.0 || ^2.0.0", "webpack": "^5.0.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-e7A7ubt+D+MzXAlQcOaDaHMD5oYzxULVdZVjLzGVStIXebGfD4RcVHxBSRP7xE+G8sS3o691zyfIbhm/veOLjQ=="], "@tanstack/history": ["@tanstack/history@1.162.0", "", {}, "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA=="], @@ -950,7 +770,7 @@ "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.101.2", "", { "dependencies": { "@tanstack/query-devtools": "5.101.2" }, "peerDependencies": { "@tanstack/react-query": "^5.101.2", "react": "^18 || ^19" } }, "sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ=="], - "@tanstack/react-router": ["@tanstack/react-router@1.170.17", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.14", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-ppLkjCfSMaeug9rmFRYzOd4TIqWV+yTE7tzIny7alJsSnM7w4lzEZm6eqCehG0SPetpZ0R3K+UnanSmBgOAVcQ=="], + "@tanstack/react-router": ["@tanstack/react-router@1.170.18", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.15", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ=="], "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.167.0", "", { "dependencies": { "@tanstack/router-devtools-core": "1.168.0" }, "peerDependencies": { "@tanstack/react-router": "^1.170.0", "@tanstack/router-core": "^1.170.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-nGw095EG7IHx0h5NtlEmzf6vcCTaFNPWdTSuDKazajhN0ct/v/TkekJ9J6KYUCeV1a8/2ZmToc58M+0rrOyn7w=="], @@ -958,15 +778,15 @@ "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.5", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.6", "", { "dependencies": { "@tanstack/virtual-core": "3.17.4" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA=="], - "@tanstack/router-core": ["@tanstack/router-core@1.171.8", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-PbrTBbofFcacrH3RLgHYILRqTFnAGq+gXrXoA/vo7qUSkJpSO4GWfLtrtCahD4VayzRm19IPwcjPPLEugag6pw=="], + "@tanstack/router-core": ["@tanstack/router-core@1.171.15", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA=="], "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.168.0", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.170.0", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-wQoQhlBK7nlZgqzaqdYXKWNTpdHdsaREdaPhFZVH0/Ador+F+eM3/NF2i3f2LPeS0GgKraZUQXe1Q/1+KHyEYg=="], - "@tanstack/router-generator": ["@tanstack/router-generator@1.167.18", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.14", "@tanstack/router-utils": "1.162.2", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-kFvM4caRds9Q3EXg64bZubJ6rbDxyV0YDSBSGvOGzmKspQPdz5Xrh0uj5T1Ov8avUUg+c761u04VQAaEzSBXRw=="], + "@tanstack/router-generator": ["@tanstack/router-generator@1.167.21", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.15", "@tanstack/router-utils": "1.162.2", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-m3oXZyienj8owialdyoZ0txHQrnEx/Ra+D9kWtar5fC2cWZr5Pvxl86VY2mX5RRLC5QLKLeRGT1x4HV95wHVDQ=="], - "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.19", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.14", "@tanstack/router-generator": "1.167.18", "@tanstack/router-utils": "1.162.2", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.17", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-aFglwLc+bbPTgZlkXn3PvOwpjJAfgUyPGSuql4MP3XrqTTh6WkBiy2RYb6oaG5h0s7EKwivEuq85K3Y4V0Mt1g=="], + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.23", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.15", "@tanstack/router-generator": "1.167.21", "@tanstack/router-utils": "1.162.2", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.18", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-0+PIcvnaAimFwjoEIeV3h7LKjzC8zNnp7pH2UamdKwQ9QlY99WU9V0Xl0zbM0i9hrUa/mKgWPDAzELmPUu5fMA=="], "@tanstack/router-utils": ["@tanstack/router-utils@1.162.2", "", { "dependencies": { "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ=="], @@ -974,76 +794,10 @@ "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.4", "", {}, "sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], - "@tiptap/core": ["@tiptap/core@3.24.0", "", { "peerDependencies": { "@tiptap/pm": "3.24.0" } }, "sha512-GTAsXAI32p4hEZgPzvUv2RPrObxamy9AFhmhG10fXSvN/cDUs8naEYVIqDV3Sh99jMwQEbTFKW1E1mcspsY6ow=="], - - "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-DgwEEJ1GbDQcT054ynxoaZGmB9apGeUklPrinq9o6xdLHpdg+bO9HCQzggdB8n21VLLglb8jfAEWsVNwh3eASQ=="], - - "@tiptap/extension-bold": ["@tiptap/extension-bold@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-CujogYaynasklFKHADUseuvj8X2FnWktTCCo3Hl+nlyRvBTmm5TK2aqiamg3v2P4dBh3O6a70mo8BfRJPuiR1g=="], - - "@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.24.0", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-jRXD+JPu9ayvq78g8hsCxx4q/qUFtrdfIYirRSf5YUseuuUbtfrq83AsGabcygpUTefjJkMQoXNITkh6294Ggw=="], - - "@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.24.0", "", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-IOpAm5c4XVVVvkOef+V9XYMVpea+3MgBpCQgn83UQRlwO9eIMwmcyxOznu7gQPQVShTEpkt4T6uK+ZN9o8meIA=="], - - "@tiptap/extension-code": ["@tiptap/extension-code@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-MAQtrPRQ+HRmcGotWbksdIGeH1gqayFAdvi4lNGeFT7taHXP1o1XD7CQp7iYIKmg8IU4/MQ+RdetSfuC1A9edQ=="], - - "@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-NZglw4oHoH6oJ5+HvxxQCYk+wODJmsxzUpRQdsOmje08sekQH+Zt9i4UKimBhg4urpd5r+dKXTslab9a5eQ86w=="], - - "@tiptap/extension-document": ["@tiptap/extension-document@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-yxgM3+yXy2XZzEwH43y2Kp8D1BkblxEWLXqo0YCoAKtxyKCcEaT8kdlf70kS7D0+VSzYU4D0iN7VdQIYHcL2mA=="], - - "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.24.0", "", { "peerDependencies": { "@tiptap/extensions": "3.24.0" } }, "sha512-Dbv1c5LnvG3PT+yEbCNroyOeeUkHq9wcir2pbC7wri7g7d2sCi0+HvKH0MAxLwY3j5NJJSiSyG2ypMaXOAs4sg=="], - - "@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.24.0", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-7QEbf3mUzFAkejjQGX9f0L507oMtnOBRwHt2skUTR+9yXgudsN8zaDBSSRHLeMWGk9b7L293ZMA6zCRrZaHrfA=="], - - "@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.24.0", "", { "peerDependencies": { "@tiptap/extensions": "3.24.0" } }, "sha512-CzCP5/jni5RFwW9jCfBO6auh83GbaioMTpSk6tyR3sd+CbwlBcUdsJFGJkbaRdiSS9dgIyi+6hRbhjpYdHcp+w=="], - - "@tiptap/extension-hard-break": ["@tiptap/extension-hard-break@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-T/ZEBiHQPMyTqDvXG0tiqBToNeuSemIPmNtdoGSgBN/degVl7VJZqQIrLIvOUHfjf3QkRs7TE/mcqTJsIboO/g=="], - - "@tiptap/extension-heading": ["@tiptap/extension-heading@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-GCSgapIzQPqEGNcVGE0/Pcjg5wITMLYJlrS3GGVw7BPmECJwgexcoOsEwkxtzJnXT/HpFXbvOFW43sM0KeHSjg=="], - - "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-DFzWJTrb23x+qssLLs85vEyho8ItUGp3RY9XUsVTIAGZn5IsoUw8wMsvIBlH1ux4Ch7gLchtcD6kpTdMdrL9kw=="], - - "@tiptap/extension-image": ["@tiptap/extension-image@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-mH+bvsX2cPKuZzV7YMQi4FV2YbDP+Kmq36bY+Bwi/x4mYUc8u0cjQxcu8RzLO7GtsgUJPxGMwfkQxmDqXFLZvw=="], - - "@tiptap/extension-italic": ["@tiptap/extension-italic@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-mf3cbNlbMPUNj3IyUkIke+o3ZpOUrtVeY5Yqs5IM/VhkUUh/PdIzqw74VuqEAJ0Z4oZ6nNDHeYLrl3Be1j99lQ=="], - - "@tiptap/extension-link": ["@tiptap/extension-link@3.24.0", "", { "dependencies": { "linkifyjs": "^4.3.3" }, "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-MwMoNGG2mL5XGFV1tEGunBRglwsIbW+ZOB2QnKiv+Mcbi2JCWMrorndJZBqpVPR5nM+Bef2KnpchEJmYlQLvKQ=="], - - "@tiptap/extension-list": ["@tiptap/extension-list@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-GcxDVMMmDGj7OFTBrV7JpVgr5wxlr2vmjwH7U8QxZX7OJI5vrsMYl/U6KRTvUpG8wP+Zmo5jRlLM+BbL+a/W3g=="], - - "@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.24.0", "", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-zl/U3viJiV9OzkKM37AHIUN1af1TSLrcbHUUoNLkfJ33Nq+NlpaXpCVK0rKRqiLFJf7zk/a5KWG5CrOy9TxjKA=="], - - "@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.24.0", "", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-69fKcrngYGEKWNn4R5oLwl0YuV3FY4kufEValVcjnihUmqJTE1vx+fwctYoTsOGnIuNGpUIQ7f9YDD/0w34qBw=="], - - "@tiptap/extension-mention": ["@tiptap/extension-mention@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0", "@tiptap/suggestion": "3.24.0" } }, "sha512-c68AYrEoHJ4vlBvt5stBUTveKXiNwt5BxaQxgq2R4OXjc3VMoh+XJqo1bBbMNHEJfuGMNpcdfZ2zf09jnBf8/A=="], - - "@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.24.0", "", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-buRa6bmBDw0TztH+rAcusIye14DiLDS+yGheo6GiNCTD7kKJnksXagBdxvip3jhW5sx7gyAKvoBmvGSg1BbsGA=="], - - "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-wD06aB6hO7LgcrlhGiw7I64k2tus9kNoICX5R+UecBSB1DVJdzKvXoXL2kPNv4DqYvljHdkIeK/OpuOTQd6MJA=="], - - "@tiptap/extension-strike": ["@tiptap/extension-strike@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-sfN1iQs6Fdlorrfe8wipDkTPwu/Egx3s2fkY7TAWusTGFHwlovuRUGFKqCL9dI4N3u6uqUMpEuWmQNgv+aQGjQ=="], - - "@tiptap/extension-text": ["@tiptap/extension-text@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-Im7keLPEihxm3+LyF+drYCoaOY5hlq35lvHAp/el6M8pJ/scts88HrYpdR1Yc4BtpZBIhfHSyWgPaupI4qwdeg=="], - - "@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-WKFtYXGthtkUc+Cwy2fItSr+9FKwLZjkJVAY1GhkRdcq35qTuVhkb4Q4wR2Rhkb6QRqtlxF1NDuTf2vxiQmfBQ=="], - - "@tiptap/extension-text-style": ["@tiptap/extension-text-style@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-1Hy+5tFEAsnoLhZ/eqmza4USvFHwMA8haeAdCGlwTeshBrt+nUKTrEsRHidF60cGsRwlTcuqxSkjT94dULgp5Q=="], - - "@tiptap/extension-underline": ["@tiptap/extension-underline@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-D4W4X3UMq9dLVIOfPB9+UodQ4eAJ8yDcm8qFWAwq0a15YWH6bnwulCuIdV+U5dEG+yaRxN8haB9GrrID9jmrSA=="], - - "@tiptap/extensions": ["@tiptap/extensions@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-z6gRYzy2ucJp07OQ0F2W07NxyhMTxPYH1ia2eGiQkWax1i56oExpjMsDHP8THWlg8Tb7NnbfKpkfh881EsmofA=="], - - "@tiptap/pm": ["@tiptap/pm@3.24.0", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.2", "prosemirror-model": "^1.24.1", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.38.1" } }, "sha512-QQP/78ryOZDN99gNBV7dgh69/8AYaOYQYFklq/iR+ZRFaaL3+qqHFvPVJapGkzPdymBgNJ34xjFM8n5pJ4QmMg=="], - - "@tiptap/react": ["@tiptap/react@3.24.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "^3.24.0", "@tiptap/extension-floating-menu": "^3.24.0" }, "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-KxnrlQbzOgA02EMsfuGGHtNhfkJQGqVlQttmQctI9DOl/F3gcaRqg+wNTBY1Fof8yDaZ8Z1LL1F0C05W0o3vUw=="], - - "@tiptap/starter-kit": ["@tiptap/starter-kit@3.24.0", "", { "dependencies": { "@tiptap/core": "^3.24.0", "@tiptap/extension-blockquote": "^3.24.0", "@tiptap/extension-bold": "^3.24.0", "@tiptap/extension-bullet-list": "^3.24.0", "@tiptap/extension-code": "^3.24.0", "@tiptap/extension-code-block": "^3.24.0", "@tiptap/extension-document": "^3.24.0", "@tiptap/extension-dropcursor": "^3.24.0", "@tiptap/extension-gapcursor": "^3.24.0", "@tiptap/extension-hard-break": "^3.24.0", "@tiptap/extension-heading": "^3.24.0", "@tiptap/extension-horizontal-rule": "^3.24.0", "@tiptap/extension-italic": "^3.24.0", "@tiptap/extension-link": "^3.24.0", "@tiptap/extension-list": "^3.24.0", "@tiptap/extension-list-item": "^3.24.0", "@tiptap/extension-list-keymap": "^3.24.0", "@tiptap/extension-ordered-list": "^3.24.0", "@tiptap/extension-paragraph": "^3.24.0", "@tiptap/extension-strike": "^3.24.0", "@tiptap/extension-text": "^3.24.0", "@tiptap/extension-underline": "^3.24.0", "@tiptap/extensions": "^3.24.0", "@tiptap/pm": "^3.24.0" } }, "sha512-Ef4PCP96vcY2GonXN9J0M8iC6zvxPTmQlL/QZiCwuYqqnH/hNpYIjNSQdTndiDpxRKofa32Sr2HWktgEnL32Bg=="], - - "@tiptap/suggestion": ["@tiptap/suggestion@3.24.0", "", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-UlLIij1fxFy7tbCmqUoInWRijzsi8hsbaXKCx6L3KvLXtxHb4hMnDhd6W++rOk9Q1hDpmNf8qNIX498q/ZNstw=="], - "@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="], "@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="], @@ -1068,7 +822,7 @@ "@turf/rewind": ["@turf/rewind@6.5.0", "", { "dependencies": { "@turf/boolean-clockwise": "^6.5.0", "@turf/clone": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "@turf/meta": "^6.5.0" } }, "sha512-IoUAMcHWotBWYwSYuYypw/LlqZmO+wcBpn8ysrBNbazkFNkLf3btSDZMkKJO/bvOzl55imr/Xj4fi3DdsLsbzQ=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], @@ -1112,7 +866,7 @@ "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], - "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + "@types/d3-random": ["@types/d3-random@3.0.4", "", {}, "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA=="], "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], @@ -1140,7 +894,7 @@ "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], - "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], "@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="], @@ -1152,11 +906,11 @@ "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], - "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], + "@types/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="], + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], @@ -1172,23 +926,23 @@ "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], - "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260702.3", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260702.3", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260702.3", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260702.3", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260702.3", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260702.3", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260702.3", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260702.3" }, "bin": { "tsgo": "bin/tsgo" } }, "sha512-j21laxUja23ex6qYGjwiSiQ6YpS/p5E7lCMDYKDBAdBv93Z1NzV96BZV3gdRwl+buH09EPuBAiqxKQqRO1grhw=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260707.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260707.2" }, "bin": { "tsgo": "bin/tsgo" } }, "sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg=="], - "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260702.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sswJ7XRSH17/jDv6eqlRyNzjQev4w+TH+16RgeoZkKbQfZnVuLchF4ORCecEuia15X2yE8pbiQGY596LVbGKdA=="], + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg=="], - "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260702.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-j8ZYT/chWtx3QT1Bghp0/+2g6aKLaxRVpXEHt6Fjdj4OXQTpH4pljOE0+LeAqaET88PUo/XM9pi0TJ5TJwHLMg=="], + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Afc7M5zOwo+GpfcYwz5Z8HMB2tPVsui7nNIqEuuFB73MPdVqNn/Wmpe4tP4MRri0AtJnJknoHBaTJ/VDAp/Jhw=="], - "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260702.3", "", { "os": "linux", "cpu": "arm" }, "sha512-SM73Mp3oYUWkyHmym0MVfPnMeLGM6oo1vcPZTxVoL7BOKWkJKK+iM7D1tcqDSZNPwURSkWMxP17Dh+4JPqzikg=="], + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2", "", { "os": "linux", "cpu": "arm" }, "sha512-hJm/UOqZTr9FHmR7uNm8VGX4oKtfWk0Jem0zPeJFNC8ckGUfSBueyiEYMZB+XmRc1aG4x1E46y3CplP4CLHvGQ=="], - "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260702.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-FEBt9UER27yvn3o4quXZ9fQ/Hd39hxFPljJe8dxstCvyYLh0A9TwDuuwghWCisp5U9TDWWtl8m1GLjvqIKc6IA=="], + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-iITBa2WjjTI5N9t5l7Z4KoOSI+2zBlhbvFzsD/f8qX8QoKjz/Y4DPyBDgezYi8nkqjjksbgSOJ3/ykzhwrB9cg=="], - "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260702.3", "", { "os": "linux", "cpu": "x64" }, "sha512-CY/r7gHjDnf3xWptpQVRQnagMdUDbh+zL01SF+cTJsg1WVY3TtVNrCo48dwAnJL+U97qx1SKaGVsi/8cSPwrZA=="], + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2", "", { "os": "linux", "cpu": "x64" }, "sha512-du0dzi6y97Po5vDNdPJTyyijHCpaS22JLRnKZEJXBDaO9gCIymOv/5QQokFRuOlQm0bWl3i9PF4OVdGP6uAOQA=="], - "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260702.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-9U/ahdJAWZdYxPdNry3VAhB9avaQCiqsEk2PfheeKrKSKCtfs5FoPXy/QXAaIGIBK/Lw4a7OqyVyEbd0i77TkQ=="], + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-SsAwfhyHJ1akgBc+99z4+hwdbHsdWaKB8EwCNIMA6JfSLMeUjffrYvxu+vfMyxVtOVOz7RrRXRoiDiu4a2sCtg=="], - "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260702.3", "", { "os": "win32", "cpu": "x64" }, "sha512-kPqfivHuzzEK2NFEl2QeCCiWJ4qkGMJYFX9TmJ4ylh784XICgQLX3PJe7OCqZcUicfeMIIDu6m3flcCi+4tW4Q=="], + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2", "", { "os": "win32", "cpu": "x64" }, "sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], @@ -1198,63 +952,43 @@ "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], - "@visactor/react-vchart": ["@visactor/react-vchart@2.1.2", "", { "dependencies": { "@visactor/vchart": "2.1.2", "@visactor/vchart-extension": "2.1.2", "@visactor/vrender-core": "^1.1.3", "@visactor/vrender-kits": "^1.1.3", "@visactor/vutils": "~1.0.23", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-7EadIvN/ORSTvSNw5cvGL53X+dKWFVwa5J2k7n99PA3xSAGnpZl2tBR9uKRf9BFOsTN8RmniO1dafJDuRTnvaw=="], + "@visactor/react-vchart": ["@visactor/react-vchart@2.1.4", "", { "dependencies": { "@visactor/vchart": "2.1.4", "@visactor/vchart-extension": "2.1.4", "@visactor/vrender-core": "~1.1.4", "@visactor/vrender-kits": "~1.1.4", "@visactor/vutils": "~1.0.23", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-6QTmGm3bsphFeulPwLDxVJccnFXqkqPnyKiuOo3hwyvXD4W4TTiJl5hR9iT7hr8PANGcSmiMKDNzbWKRJx5/jw=="], - "@visactor/vchart": ["@visactor/vchart@2.1.2", "", { "dependencies": { "@visactor/vdataset": "~1.0.23", "@visactor/vlayouts": "~1.0.23", "@visactor/vrender": "^1.1.3", "@visactor/vrender-animate": "^1.1.3", "@visactor/vrender-components": "^1.1.3", "@visactor/vrender-core": "^1.1.3", "@visactor/vrender-kits": "^1.1.3", "@visactor/vscale": "~1.0.23", "@visactor/vutils": "~1.0.23", "@visactor/vutils-extension": "2.1.2" } }, "sha512-mJqm7LoC4lBs7e+5tCPtZJjqh/+DebXrcBzea54iuetVOxBcVJU2Ph731lMnegVwbbccZSTJN9xjxXiTiCtuMw=="], + "@visactor/vchart": ["@visactor/vchart@2.1.4", "", { "dependencies": { "@visactor/vdataset": "~1.0.23", "@visactor/vlayouts": "~1.0.23", "@visactor/vrender": "~1.1.4", "@visactor/vrender-animate": "~1.1.4", "@visactor/vrender-components": "~1.1.4", "@visactor/vrender-core": "~1.1.4", "@visactor/vrender-kits": "~1.1.4", "@visactor/vscale": "~1.0.23", "@visactor/vutils": "~1.0.23", "@visactor/vutils-extension": "2.1.4" } }, "sha512-NueAYq9hg9QHbsDK5WBZzOktQZiBdumMjE9mI5BKTJ9zzTOT+A2BfyK4xfFELojceXDwRs9sEH4xf93G2kexOw=="], - "@visactor/vchart-extension": ["@visactor/vchart-extension@2.1.2", "", { "dependencies": { "@visactor/vchart": "2.1.2", "@visactor/vdataset": "~1.0.23", "@visactor/vlayouts": "~1.0.23", "@visactor/vrender-animate": "^1.1.3", "@visactor/vrender-components": "^1.1.3", "@visactor/vrender-core": "^1.1.3", "@visactor/vrender-kits": "^1.1.3", "@visactor/vutils": "~1.0.23" } }, "sha512-iZ4DESeOObKU2oREeqBQ/vYgI3gM+pTGnrqeVkm5RECbeuS44IPOSrkNh4mCcSdJO36nAcfJ5MqYhK4fh7pxug=="], - - "@visactor/vchart-semi-theme": ["@visactor/vchart-semi-theme@1.8.8", "", { "dependencies": { "@visactor/vchart-theme-utils": "1.8.8" }, "peerDependencies": { "@visactor/vchart": "~1.8.8" } }, "sha512-lm57CX3r6Bm7iGBYYyWhDY+1BvkyhNVLEckKx2PnlPKpJHikKSIK2ACyI5SmHuSOOdYzhY2QK6ZfYa2NShJ83w=="], - - "@visactor/vchart-theme-utils": ["@visactor/vchart-theme-utils@1.8.8", "", { "peerDependencies": { "@visactor/vchart": "~1.8.8" } }, "sha512-RdCey3/t0+82EYyFZvx210rgJJWti9rsgcL3ROZS7o9CtRW1CMj9u9LKLDNIcPLNcLNACFC0aoT03jpdD1BCpA=="], + "@visactor/vchart-extension": ["@visactor/vchart-extension@2.1.4", "", { "dependencies": { "@visactor/vchart": "2.1.4", "@visactor/vdataset": "~1.0.23", "@visactor/vlayouts": "~1.0.23", "@visactor/vrender-animate": "~1.1.4", "@visactor/vrender-components": "~1.1.4", "@visactor/vrender-core": "~1.1.4", "@visactor/vrender-kits": "~1.1.4", "@visactor/vutils": "~1.0.23" } }, "sha512-17uvyKW3/2rS9wSAhWbhh2JgB1DKXH122vWm5HJrUqG8BVLk4JxaMdDyDRZBo2USqf8ehzjv5vfxwpffJ4kPhg=="], "@visactor/vdataset": ["@visactor/vdataset@1.0.23", "", { "dependencies": { "@turf/flatten": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/rewind": "^6.5.0", "@visactor/vutils": "1.0.23", "d3-dsv": "^2.0.0", "d3-geo": "^1.12.1", "d3-hexbin": "^0.2.2", "d3-hierarchy": "^3.1.1", "eventemitter3": "^4.0.7", "geobuf": "^3.0.1", "geojson-dissolve": "^3.1.0", "path-browserify": "^1.0.1", "pbf": "^3.2.1", "point-at-length": "^1.1.0", "simple-statistics": "^7.7.3", "simplify-geojson": "^1.0.4", "topojson-client": "^3.1.0" } }, "sha512-zrLk9FBUWJoW6b30XnPKzXwAXl8USdLDfed6QZLsmdkylRU8V7yZeXE2aKwU8Lg1U4HmQngqmqOx7/QlbX44Tg=="], - "@visactor/vgrammar-coordinate": ["@visactor/vgrammar-coordinate@0.10.11", "", { "dependencies": { "@visactor/vgrammar-util": "0.10.11", "@visactor/vutils": "~0.17.3" } }, "sha512-XSUvEkaf/NQHFafmTwqoIMZicp9fF3o6NB2FDpuWrK4DI1lTuip/0RkqrC+kBAjc5erjt0em0TiITyqXpp4G6w=="], - - "@visactor/vgrammar-core": ["@visactor/vgrammar-core@0.10.11", "", { "dependencies": { "@visactor/vdataset": "~0.17.3", "@visactor/vgrammar-coordinate": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-components": "0.17.17", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-VL9vcLPDg1LrHl7EOx0Ga9ATsoaChKIaCGzxjrPEjWiIS5VPU9Rs0jBKP+ch8BjamAoSuqL5mKd0L/RaUBqlaA=="], - - "@visactor/vgrammar-hierarchy": ["@visactor/vgrammar-hierarchy@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vutils": "~0.17.3" } }, "sha512-0r3k51pPlJHu63BduG3htsV/ul62aVcKJxFftRfvKkwGjm1KeHoOZEEAwIf78U2puio0BkLqVn2Ek2L4FYZaIg=="], - - "@visactor/vgrammar-projection": ["@visactor/vgrammar-projection@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vutils": "~0.17.3", "d3-geo": "^1.12.1" } }, "sha512-yEiKsxdfs5+g60wv5xZ1kyS/EDrAsUzAxCMpFFASVUYbQObHvW+elm+UPq2TBX6KZqAM0gsd1inzaLvfsCrLSg=="], - - "@visactor/vgrammar-sankey": ["@visactor/vgrammar-sankey@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vutils": "~0.17.3" } }, "sha512-BbJTPuyydsL/L5XtQv59Q82GgJeePY7Wleac798usx3GnDK0GAOrPsI3bubSsOESJ4pNk3V4HPGEQDG1vCPb4w=="], - - "@visactor/vgrammar-util": ["@visactor/vgrammar-util@0.10.11", "", { "dependencies": { "@visactor/vutils": "~0.17.3" } }, "sha512-cJZLmKZvN95Y+yGhX+28+UpZu3bhYYlXDlHJNvXHyonI76ZYgtceyon2b3lI6XIsUsBGcD4Uo777s949X5os3g=="], - - "@visactor/vgrammar-wordcloud": ["@visactor/vgrammar-wordcloud@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vutils": "~0.17.3" } }, "sha512-JWDqjGhr9JlYkKVBeEkiOqLQk7C1x1BtnsZ+E8oN541gzUqHwfS9qZyhwI3OyoSLewJlsSSPu1vXLKSQzLzKPA=="], - - "@visactor/vgrammar-wordcloud-shape": ["@visactor/vgrammar-wordcloud-shape@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-NsQOYJp+9WHnIApMvkcUOaajxIg5U/r6rD8LKnoXW/HqAN2TFYXcRR3Daqmk9rrpM5VztQimKOsA1yZWyzozrA=="], - "@visactor/vlayouts": ["@visactor/vlayouts@1.0.23", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "@visactor/vscale": "1.0.23", "@visactor/vutils": "1.0.23", "eventemitter3": "^4.0.7" } }, "sha512-fK1f5LmuumhYanLArk5yrT4BZxu4IAmdc8WMwfB/KAvV+2dTPFuBUMWbWnDl0siQoU9SX9l/bLozUnI9n7BwBQ=="], - "@visactor/vrender": ["@visactor/vrender@1.1.4", "", { "dependencies": { "@visactor/vrender-animate": "1.1.4", "@visactor/vrender-components": "1.1.4", "@visactor/vrender-core": "1.1.4", "@visactor/vrender-kits": "1.1.4" } }, "sha512-+T09pS5EJ2HizxMCa3r8MuvgcWnoJpGUK2ykLZLcR6w+5kfpfbu2B8uQzetTOKn1CVIhDbuO05monfI7mCYGQA=="], + "@visactor/vrender": ["@visactor/vrender@1.1.5", "", { "dependencies": { "@visactor/vrender-animate": "1.1.5", "@visactor/vrender-components": "1.1.5", "@visactor/vrender-core": "1.1.5", "@visactor/vrender-kits": "1.1.5" } }, "sha512-iM1kJpvxK0PwBi6sxfxRsSdHJH02nn9pIQc4Kz6WAC4JRlqRjxGPZXa1Cn3FvpJPzUVPDDVY22Y83Hl/eSt5Mw=="], - "@visactor/vrender-animate": ["@visactor/vrender-animate@1.1.4", "", { "dependencies": { "@visactor/vrender-core": "1.1.4", "@visactor/vutils": "~1.0.12" } }, "sha512-XBaCXMLSNw9E+htAEaqjby28mdINyVokNkLjKJ/ukmGobi3XX8siekXTNzCfe9M9UG4QGRTqyph848sxLB02Tw=="], + "@visactor/vrender-animate": ["@visactor/vrender-animate@1.1.5", "", { "dependencies": { "@visactor/vrender-core": "1.1.5", "@visactor/vutils": "~1.0.12" } }, "sha512-SpS+BGAgC6w2/Nk0laI+8j8Cj8of7xbdgoItv7ErcwT7/bMMZqdTh5hI7vM3addwcridYn4tUpKfXsjFT6cmPg=="], - "@visactor/vrender-components": ["@visactor/vrender-components@1.1.4", "", { "dependencies": { "@visactor/vrender-animate": "1.1.4", "@visactor/vrender-core": "1.1.4", "@visactor/vrender-kits": "1.1.4", "@visactor/vscale": "~1.0.12", "@visactor/vutils": "~1.0.12" } }, "sha512-N1bnIuefe6Lms7Ij8NGjKfNUYQpZ+M8RYsnjBR2So0wmnxAnXUTgqbpWDT9DiXzRF17qYSkF0HZJIdS/wN2o3Q=="], + "@visactor/vrender-components": ["@visactor/vrender-components@1.1.5", "", { "dependencies": { "@visactor/vrender-animate": "1.1.5", "@visactor/vrender-core": "1.1.5", "@visactor/vrender-kits": "1.1.5", "@visactor/vscale": "~1.0.12", "@visactor/vutils": "~1.0.12" } }, "sha512-xIEMeU/dV8x0bJ1xjbElhtoD+Sjet7Ahoq2Fm10p/VEf1ov9u4ZoGBZZaA/oZJsrvL6WchdZAHEM5TAmId5hBw=="], - "@visactor/vrender-core": ["@visactor/vrender-core@1.1.4", "", { "dependencies": { "@visactor/vutils": "~1.0.12", "color-convert": "2.0.1" } }, "sha512-AyMKFohr/iK1Wc5jxvE/04MLcFI0VAR5qleDy+b2WwLi7UoeXn0tBhtzz72xIOKU1OrzvmflWsVmdnlh6ittwg=="], + "@visactor/vrender-core": ["@visactor/vrender-core@1.1.5", "", { "dependencies": { "@visactor/vutils": "~1.0.12", "color-convert": "2.0.1" } }, "sha512-kaAHJlmitRNZYW6U3I+r3rdYmMcYLIQWuTxo4IiXF6KhmNeYmqa12LFV9J3fYEz9eb93SOYBGmjU467EkgU0XA=="], - "@visactor/vrender-kits": ["@visactor/vrender-kits@1.1.4", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "1.1.4", "@visactor/vutils": "~1.0.12", "gifuct-js": "2.1.2", "lottie-web": "^5.12.2", "roughjs": "4.6.6" } }, "sha512-YD1y2TWh5yl6AzpW47HPcYogk0d6KntuwUrGocXqSkLXwzsGnYntE9RvAEGTu4ketdeahh+8/0w34d6BU0Hzig=="], + "@visactor/vrender-kits": ["@visactor/vrender-kits@1.1.5", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "1.1.5", "@visactor/vutils": "~1.0.12", "gifuct-js": "2.1.2", "lottie-web": "^5.12.2", "roughjs": "4.6.6" } }, "sha512-AI+N5EVYshJj0P8mnTSMgAsfACsGdX19cavCAUCS7iHec6nGX93qQg1zfH2oNZelwCAYpDvJY/yUzRYB/sozqA=="], "@visactor/vscale": ["@visactor/vscale@1.0.23", "", { "dependencies": { "@visactor/vutils": "1.0.23" } }, "sha512-XePhYuRoNAp+8MeSMuEOOvhVAlOwvM1sDT2yFxE6zdwVB2GjZk8mH+5N2xQGQWk75YmGJjlJASFtgwjlb1yWxw=="], "@visactor/vutils": ["@visactor/vutils@1.0.23", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-M8SLqgdHhKN8QmQKTWD1gzEaHptpIV9pvMYvC6+VeOsqYvZZ6UdhSCAAczTYVo+m/uwcEC2JHSUspbrs8rzlRQ=="], - "@visactor/vutils-extension": ["@visactor/vutils-extension@2.1.2", "", { "dependencies": { "@visactor/vdataset": "~1.0.23", "@visactor/vutils": "~1.0.23" } }, "sha512-9ss8NpeD5N8oo0dgHo1ZIRVJZ0oEFQbMMD4xBtsXqmz0TCFyFu2icoxAdTupjbo/3GtdZX7wqayPkEIm6Za+Ow=="], + "@visactor/vutils-extension": ["@visactor/vutils-extension@2.1.4", "", { "dependencies": { "@visactor/vdataset": "~1.0.23", "@visactor/vutils": "~1.0.23" } }, "sha512-gyTxhrTN0Ybzis4FsHniTNQSKo9GWLT2f+0du3E6wEDQ/PijNRS6onkY3HyBXZbsnJlvk/sMZUqrtVJi3cTvzg=="], "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], - "@xyflow/react": ["@xyflow/react@12.11.1", "", { "dependencies": { "@xyflow/system": "0.0.78", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q=="], + "@xyflow/react": ["@xyflow/react@12.11.2", "", { "dependencies": { "@xyflow/system": "0.0.79", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA=="], - "@xyflow/system": ["@xyflow/system@0.0.78", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g=="], + "@xyflow/system": ["@xyflow/system@0.0.79", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA=="], "abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -1262,30 +996,22 @@ "ahooks": ["ahooks@3.9.7", "", { "dependencies": { "@babel/runtime": "^7.21.0", "@types/js-cookie": "^3.0.6", "dayjs": "^1.9.1", "intersection-observer": "^0.12.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "react-fast-compare": "^3.2.2", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-S0lvzhbdlhK36RFBkGv+RbOM/dbbweym+BIHM/bwwuWVSVN5TuVErHPMWo4w0t1NDYg5KPp2iEf7Y7E5LASYiw=="], - "ai": ["ai@7.0.14", "", { "dependencies": { "@ai-sdk/gateway": "4.0.11", "@ai-sdk/provider": "4.0.2", "@ai-sdk/provider-utils": "5.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qA82fZyD4xh9IDB+s3SvwbjwjR+GGRmJjTYMwON1uROj8vPDIQbPTZLLq6ZWTVESn9daeH1GMv0zKfVLwNU2sQ=="], + "ai": ["ai@7.0.31", "", { "dependencies": { "@ai-sdk/gateway": "4.0.23", "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.11" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-pJfwKXjF5kw0rKRTePwYo60EfWb8wfzJAgf3ojln/YkOsVVKttzZAJVcRPsg37Z3a06ZdKkxX+DSrMAFlPm5Mw=="], - "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], - "antd": ["antd@6.4.3", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.2.3", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.29.2", "@rc-component/cascader": "~1.15.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.9.0", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.2", "@rc-component/form": "~1.8.1", "@rc-component/image": "~1.9.0", "@rc-component/input": "~1.3.0", "@rc-component/input-number": "~1.6.2", "@rc-component/mentions": "~1.9.0", "@rc-component/menu": "~1.3.0", "@rc-component/motion": "^1.3.2", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~2.0.7", "@rc-component/pagination": "~1.2.0", "@rc-component/picker": "~1.10.0", "@rc-component/progress": "~1.0.2", "@rc-component/qrcode": "~1.1.1", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.6.15", "@rc-component/slider": "~1.0.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.10.0", "@rc-component/tabs": "~1.9.0", "@rc-component/tooltip": "~1.4.0", "@rc-component/tour": "~2.4.0", "@rc-component/tree": "~1.3.1", "@rc-component/tree-select": "~1.9.0", "@rc-component/trigger": "^3.9.0", "@rc-component/upload": "~1.1.0", "@rc-component/util": "^1.11.0", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-6H2avkxCGfxcF67r3J2mwm9Ck50el1pks/73vfM1wDsPL/tPtj5vHuauMgJFnrqmq7CH3g8aoZ0VBQbt+jpAsw=="], + "antd": ["antd@6.5.1", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.3.2", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.29.2", "@rc-component/cascader": "~1.17.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.10.0", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.3", "@rc-component/form": "~1.8.5", "@rc-component/image": "~1.9.0", "@rc-component/input": "~1.3.1", "@rc-component/input-number": "~1.6.2", "@rc-component/mentions": "~1.10.0", "@rc-component/menu": "~1.4.1", "@rc-component/motion": "^1.3.3", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~2.0.7", "@rc-component/pagination": "~1.4.0", "@rc-component/picker": "~1.11.0", "@rc-component/progress": "~1.0.2", "@rc-component/qrcode": "~2.0.0", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.8.2", "@rc-component/slider": "~1.1.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.10.4", "@rc-component/tabs": "~1.11.0", "@rc-component/tooltip": "~1.4.0", "@rc-component/tour": "~2.4.0", "@rc-component/tree": "~1.3.2", "@rc-component/tree-select": "~1.11.0", "@rc-component/trigger": "^3.10.0", "@rc-component/upload": "~1.1.1", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-VZVVF9zYI6S0NHqboVhCoY9Iiqj6dphW1NPB+sEaAf2HuIQ0haXWXj7ZvAXTRDzusktV6+cvvrSZEdRi4twATg=="], "antd-style": ["antd-style@4.1.0", "", { "dependencies": { "@ant-design/cssinjs": "^2.0.0", "@babel/runtime": "^7.24.1", "@emotion/cache": "^11.11.0", "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.4", "@emotion/serialize": "^1.1.3", "@emotion/utils": "^1.2.1", "use-merge-value": "^1.2.0" }, "peerDependencies": { "antd": ">=6.0.0", "react": ">=18" } }, "sha512-vnPBGg0OVlSz90KRYZhxd89aZiOImTiesF+9MQqN8jsLGZUQTjbP04X9jTdEfsztKUuMbBWg/RmB/wHTakbtMQ=="], - "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], - - "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], - - "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -1298,16 +1024,14 @@ "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], - "async-validator": ["async-validator@3.5.2", "", {}, "sha512-8eLCg00W9pIRZSB781UUX/H6Oskmm8xloZfr09lz5bikRpBVDlJ3hRVuxxP1SxcwsEYfJ4IU8Q19Y8/893r3rQ=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], + "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], "auto-skeleton-react": ["auto-skeleton-react@1.0.5", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-e7299X8Rm6dXMUU2FlIJBNSOrit65GsyHzPhtkGX9Mf7u3zfGduSRazZrT7h2XAXRGye5nPayIjyNoG4oHFxcQ=="], - "autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="], - "axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], @@ -1318,21 +1042,15 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="], - - "bezier-easing": ["bezier-easing@2.1.0", "", {}, "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig=="], - - "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], - - "binary-searching": ["binary-searching@2.0.5", "", {}, "sha512-v4N2l3RxL+m4zDxyxz3Ne2aTmiPn8ZUpKFpdPtO+ItW1NcTCXA7JeHG5GMBSvoKSkQZ9ycS+EouDVxYB9ufKWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + "brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -1346,13 +1064,11 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@4.1.1", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -1362,8 +1078,6 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "chroma-js": ["chroma-js@3.2.0", "", {}, "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw=="], @@ -1378,8 +1092,6 @@ "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], - "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], @@ -1400,14 +1112,12 @@ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "comment-parser": ["comment-parser@1.4.7", "", {}, "sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ=="], - - "compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="], - - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], + "conf": ["conf@10.2.0", "", { "dependencies": { "ajv": "^8.6.3", "ajv-formats": "^2.1.1", "atomically": "^1.7.0", "debounce-fn": "^4.0.0", "dot-prop": "^6.0.1", "env-paths": "^2.2.1", "json-schema-typed": "^7.0.3", "onetime": "^5.1.2", "pkg-up": "^3.1.0", "semver": "^7.3.5" } }, "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -1420,17 +1130,15 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - "copy-text-to-clipboard": ["copy-text-to-clipboard@2.2.0", "", {}, "sha512-WRvoIdnTs1rgPMkgA2pUOa/M4Enh2uzCwdKsOMYNAJiz/4ZvEJgmbF4OmninPmlFdAWisfeh0tH+Cpf7ni3RqQ=="], - "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], - "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], + "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], - "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1438,7 +1146,7 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "cytoscape": ["cytoscape@3.33.4", "", {}, "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww=="], + "cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="], "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], @@ -1514,10 +1222,10 @@ "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], - "date-fns-tz": ["date-fns-tz@1.3.8", "", { "peerDependencies": { "date-fns": ">=2.0.0" } }, "sha512-qwNXUFtMHTTU6CFSFjoJ80W8Fzzp24LntbjFFBgL/faqds4e5mo9mftoRLgr3Vi1trISsg4awSpYVsOQCRnapQ=="], - "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], + "debounce-fn": ["debounce-fn@4.0.0", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], @@ -1528,8 +1236,6 @@ "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], @@ -1552,25 +1258,19 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], - "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], - "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], - - "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], - "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], + "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="], - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="], + "electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="], "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], @@ -1584,7 +1284,7 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], + "enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="], "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], @@ -1602,7 +1302,7 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "es-toolkit": ["es-toolkit@1.47.0", "", {}, "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw=="], + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], @@ -1614,26 +1314,8 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.0", "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ=="], - - "eslint-plugin-header": ["eslint-plugin-header@3.1.1", "", { "peerDependencies": { "eslint": ">=7.7.0" } }, "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg=="], - - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], - - "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - - "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], - - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], @@ -1648,8 +1330,6 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -1662,32 +1342,18 @@ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "express-rate-limit": ["express-rate-limit@8.6.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], - "fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], - "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], - - "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], - "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], - "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], @@ -1696,9 +1362,7 @@ "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], - "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], - - "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="], + "file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="], "file-source": ["file-source@0.6.1", "", { "dependencies": { "stream-source": "0.3" } }, "sha512-1R1KneL7eTXmXfKxC10V/9NeGOdbsAXJ+lQ//fvvcHUgtaZcZDWNJNblxAoVOyV1cj45pOtUrR3vZTBwqcW8XA=="], @@ -1710,33 +1374,23 @@ "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - - "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], - - "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], - "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], - - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1774,11 +1428,7 @@ "giscus": ["giscus@1.6.0", "", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="], - "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="], @@ -1786,12 +1436,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], - "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -1826,13 +1472,9 @@ "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], - - "history": ["history@5.3.0", "", { "dependencies": { "@babel/runtime": "^7.7.6" } }, "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ=="], - "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], - "hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], + "hono": ["hono@4.12.22", "", {}, "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw=="], "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], @@ -1846,38 +1488,28 @@ "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], - "i18next": ["i18next@26.3.4", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA=="], + "i18next": ["i18next@26.3.6", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA=="], "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="], - "i18next-cli": ["i18next-cli@1.58.1", "", { "dependencies": { "@croct/json5-parser": "^0.2.2", "@swc/core": "^1.15.26", "chokidar": "^5.0.0", "commander": "^14.0.3", "execa": "^9.6.1", "glob": "^13.0.6", "i18next-resources-for-ts": "^2.1.0", "inquirer": "^13.4.1", "jiti": "^2.6.1", "jsonc-parser": "^3.3.1", "magic-string": "^0.30.21", "minimatch": "^10.2.5", "ora": "^9.3.0", "react": "^19.2.5", "react-i18next": "^17.0.7", "yaml": "^2.8.3" }, "bin": { "i18next-cli": "dist/esm/cli.js" } }, "sha512-vpTtfeCm4LvGV16aLX421wZqD20g4Z8LD0yIC4m0x02rVMdUJXOUVm7l8Xxl1+6OwWq/8InR17RGZN9QfsHScQ=="], - - "i18next-resources-for-ts": ["i18next-resources-for-ts@2.1.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@swc/core": "^1.15.18", "chokidar": "^5.0.0", "yaml": "^2.8.2" }, "bin": { "i18next-resources-for-ts": "bin/i18next-resources-for-ts.js" } }, "sha512-n5UexwEVt0OoIAhG2MWpSnAVJW1U8mQrQTmXyxc5DMAx+NLhcLZhSMJo/FnUsA5JQ3obTYqTgB7YIuZKWpDgow=="], - - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], + "immer": ["immer@11.1.15", "", {}, "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], - "inquirer": ["inquirer@13.4.3", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.10", "@inquirer/prompts": "^8.4.3", "@inquirer/type": "^4.0.5", "mute-stream": "^3.0.0", "run-async": "^4.0.6", "rxjs": "^7.8.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-EPd3IqieHSavSOXh+LZhrIkdQcOELWeRblLT6kslQr+cF9XTh/HxZdSt1YkHH1iq4dvqBnV42uwg2YlorgOy6g=="], - "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "intersection-observer": ["intersection-observer@0.12.2", "", {}, "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg=="], @@ -1892,13 +1524,11 @@ "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], - "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], - "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], - "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "is-extendable": ["is-extendable@1.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4" } }, "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA=="], @@ -1920,8 +1550,6 @@ "is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="], - "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], - "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], @@ -1934,11 +1562,11 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], "isarray": ["isarray@0.0.1", "", {}, "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="], - "isbot": ["isbot@5.1.40", "", {}, "sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ=="], + "isbot": ["isbot@5.2.1", "", {}, "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw=="], "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], @@ -1950,50 +1578,40 @@ "js-binary-schema-parser": ["js-binary-schema-parser@2.0.3", "", {}, "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg=="], - "js-cookie": ["js-cookie@3.0.8", "", {}, "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw=="], + "js-cookie": ["js-cookie@3.0.7", "", {}, "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - "json2mq": ["json2mq@0.2.0", "", { "dependencies": { "string-convert": "^0.2.0" } }, "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "katex": ["katex@0.17.0", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw=="], - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "knip": ["knip@6.24.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", "oxc-parser": "^0.137.0", "oxc-resolver": "11.21.3", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.1", "yaml": "^2.9.0", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-PokLlgeEjLh1rAsB7ts+52wZ37HBr1nDhE6NNONwEaXdeZGCJOkP7ZlIAI2Gtu8xohquzTWy75bc/1diI9shQw=="], + "knip": ["knip@6.27.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", "oxc-parser": "^0.137.0", "oxc-resolver": "11.21.3", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.1", "yaml": "^2.9.0", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-CngYEYrD0n20N06FXA8n3u/0Wnnugoa+B9k14OP+iKIgkCHuzvIdsP3nfwjhByoc1WfogpxfiriMboAXFETDUw=="], "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], "leva": ["leva@0.10.1", "", { "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", "@stitches/react": "^1.2.8", "@use-gesture/react": "^10.2.5", "colord": "^2.9.2", "dequal": "^2.0.2", "merge-value": "^1.0.0", "react-colorful": "^5.5.1", "react-dropzone": "^12.0.0", "v8n": "^1.3.3", "zustand": "^3.6.9" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA=="], - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -2018,13 +1636,9 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], - "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "linkify-it": ["linkify-it@5.0.1", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg=="], - - "linkifyjs": ["linkifyjs@4.3.3", "", {}, "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="], + "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], "lit": ["lit@3.3.3", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw=="], @@ -2032,14 +1646,12 @@ "lit-html": ["lit-html@3.3.3", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA=="], - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -2048,13 +1660,11 @@ "lottie-web": ["lottie-web@5.13.0", "", {}, "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ=="], - "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], - "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "lucide-react": ["lucide-react@1.25.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -2074,11 +1684,11 @@ "markdown-it-task-checkbox": ["markdown-it-task-checkbox@1.0.6", "", {}, "sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw=="], - "markdown-it-ts": ["markdown-it-ts@1.0.2", "", { "dependencies": { "@types/linkify-it": "^5.0.0", "@types/mdurl": "^2.0.0", "entities": "^4.5.0", "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" } }, "sha512-zba9mN313K2HmKk+BOHqkO/nuZtj9M1TTnUlSbItGrCMpYzc8OHGCm+IaqxWCi2pGcgpiFC8ltxkasYWYpp/YQ=="], + "markdown-it-ts": ["markdown-it-ts@1.0.4", "", { "dependencies": { "@types/linkify-it": "^5.0.0", "@types/mdurl": "^2.0.0", "entities": "^4.5.0", "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" } }, "sha512-FmCjj7D0C54EhWNn6WdSIBoyEbEoLeqE/Orgzik+CHSIz3aQJW3/U3uc8BEOMdGl/SE4O+s4BjwKhtO7LPOwWA=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "marked": ["marked@18.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -2116,14 +1726,14 @@ "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + "mdast-util-to-markdown-cjk-friendly": ["mdast-util-to-markdown-cjk-friendly@1.0.0", "", { "dependencies": { "mdast-util-to-markdown": "^2.1.2", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "@types/mdast": "*" }, "optionalPeers": ["@types/mdast"] }, "sha512-BoaAm8mlJ+LAYz0Qs532Y3ciTuQYgBUPZcSFbvC/ZKmEMAKgulw84YvQK1gI34t/vL2euSfuaWlqczkTBgamkw=="], + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], - "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], @@ -2216,16 +1826,14 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "mimic-fn": ["mimic-fn@3.1.0", "", {}, "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "mixin-deep": ["mixin-deep@1.3.2", "", { "dependencies": { "for-in": "^1.0.2", "is-extendable": "^1.0.1" } }, "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA=="], "motion": ["motion@12.42.2", "", { "dependencies": { "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q=="], @@ -2236,23 +1844,13 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], - - "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "newapi-web": ["newapi-web@workspace:default"], - "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], - "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], - - "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], @@ -2260,13 +1858,11 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], - "on-change": ["on-change@4.0.2", "", {}, "sha512-cMtCyuJmTx/bg2HCpHo3ZLeF7FZnBOapLqZHr2AlLeJ5Ul0Zu2mUJJz051Fdwu/Et2YW04ZD+TtU+gVy0ACNCA=="], + "on-change": ["on-change@4.0.0", "", {}, "sha512-PTu7C9Jsz4b+sNMDpH0eZFTr7uxdOtoDWRnhaVNK50bgrrnW5nvbWI0jm5DG9qOoTnIhBzE9xoKVFPD9xgtbdg=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -2280,25 +1876,23 @@ "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], - "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], - "oxc-parser": ["oxc-parser@0.137.0", "", { "dependencies": { "@oxc-project/types": "^0.137.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.137.0", "@oxc-parser/binding-android-arm64": "0.137.0", "@oxc-parser/binding-darwin-arm64": "0.137.0", "@oxc-parser/binding-darwin-x64": "0.137.0", "@oxc-parser/binding-freebsd-x64": "0.137.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", "@oxc-parser/binding-linux-arm64-musl": "0.137.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-musl": "0.137.0", "@oxc-parser/binding-openharmony-arm64": "0.137.0", "@oxc-parser/binding-wasm32-wasi": "0.137.0", "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", "@oxc-parser/binding-win32-x64-msvc": "0.137.0" } }, "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg=="], "oxc-resolver": ["oxc-resolver@11.21.3", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.21.3", "@oxc-resolver/binding-android-arm64": "11.21.3", "@oxc-resolver/binding-darwin-arm64": "11.21.3", "@oxc-resolver/binding-darwin-x64": "11.21.3", "@oxc-resolver/binding-freebsd-x64": "11.21.3", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-musl": "11.21.3", "@oxc-resolver/binding-openharmony-arm64": "11.21.3", "@oxc-resolver/binding-wasm32-wasi": "11.21.3", "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" } }, "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA=="], "oxfmt": ["oxfmt@0.57.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.57.0", "@oxfmt/binding-android-arm64": "0.57.0", "@oxfmt/binding-darwin-arm64": "0.57.0", "@oxfmt/binding-darwin-x64": "0.57.0", "@oxfmt/binding-freebsd-x64": "0.57.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.57.0", "@oxfmt/binding-linux-arm-musleabihf": "0.57.0", "@oxfmt/binding-linux-arm64-gnu": "0.57.0", "@oxfmt/binding-linux-arm64-musl": "0.57.0", "@oxfmt/binding-linux-ppc64-gnu": "0.57.0", "@oxfmt/binding-linux-riscv64-gnu": "0.57.0", "@oxfmt/binding-linux-riscv64-musl": "0.57.0", "@oxfmt/binding-linux-s390x-gnu": "0.57.0", "@oxfmt/binding-linux-x64-gnu": "0.57.0", "@oxfmt/binding-linux-x64-musl": "0.57.0", "@oxfmt/binding-openharmony-arm64": "0.57.0", "@oxfmt/binding-win32-arm64-msvc": "0.57.0", "@oxfmt/binding-win32-ia32-msvc": "0.57.0", "@oxfmt/binding-win32-x64-msvc": "0.57.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA=="], - "oxlint": ["oxlint@1.72.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.72.0", "@oxlint/binding-android-arm64": "1.72.0", "@oxlint/binding-darwin-arm64": "1.72.0", "@oxlint/binding-darwin-x64": "1.72.0", "@oxlint/binding-freebsd-x64": "1.72.0", "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", "@oxlint/binding-linux-arm-musleabihf": "1.72.0", "@oxlint/binding-linux-arm64-gnu": "1.72.0", "@oxlint/binding-linux-arm64-musl": "1.72.0", "@oxlint/binding-linux-ppc64-gnu": "1.72.0", "@oxlint/binding-linux-riscv64-gnu": "1.72.0", "@oxlint/binding-linux-riscv64-musl": "1.72.0", "@oxlint/binding-linux-s390x-gnu": "1.72.0", "@oxlint/binding-linux-x64-gnu": "1.72.0", "@oxlint/binding-linux-x64-musl": "1.72.0", "@oxlint/binding-openharmony-arm64": "1.72.0", "@oxlint/binding-win32-arm64-msvc": "1.72.0", "@oxlint/binding-win32-ia32-msvc": "1.72.0", "@oxlint/binding-win32-x64-msvc": "1.72.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA=="], + "oxlint": ["oxlint@1.74.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.74.0", "@oxlint/binding-android-arm64": "1.74.0", "@oxlint/binding-darwin-arm64": "1.74.0", "@oxlint/binding-darwin-x64": "1.74.0", "@oxlint/binding-freebsd-x64": "1.74.0", "@oxlint/binding-linux-arm-gnueabihf": "1.74.0", "@oxlint/binding-linux-arm-musleabihf": "1.74.0", "@oxlint/binding-linux-arm64-gnu": "1.74.0", "@oxlint/binding-linux-arm64-musl": "1.74.0", "@oxlint/binding-linux-ppc64-gnu": "1.74.0", "@oxlint/binding-linux-riscv64-gnu": "1.74.0", "@oxlint/binding-linux-riscv64-musl": "1.74.0", "@oxlint/binding-linux-s390x-gnu": "1.74.0", "@oxlint/binding-linux-x64-gnu": "1.74.0", "@oxlint/binding-linux-x64-musl": "1.74.0", "@oxlint/binding-openharmony-arm64": "1.74.0", "@oxlint/binding-win32-arm64-msvc": "1.74.0", "@oxlint/binding-win32-ia32-msvc": "1.74.0", "@oxlint/binding-win32-x64-msvc": "1.74.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA=="], + + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], - "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -2318,16 +1912,12 @@ "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], - "path-source": ["path-source@0.1.3", "", { "dependencies": { "array-source": "0.0", "file-source": "0.6" } }, "sha512-dWRHm5mIw5kw0cs3QZLNmpUWty48f5+5v9nWD2dw3Y0Hf+s01Ag8iJEWV0Sm0kocE8kK27DrIowha03e1YR+Qw=="], "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], @@ -2340,14 +1930,12 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], - - "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + "point-at-length": ["point-at-length@1.1.0", "", { "dependencies": { "abs-svg-path": "~0.1.1", "isarray": "~0.0.1", "parse-svg-path": "~0.1.1" } }, "sha512-nNHDk9rNEh/91o2Y8kHLzBLNpLf80RYd2gCun9ss+V0ytRSf6XhryBTx071fesktjbachRmGuUbId+JQmzhRXw=="], "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -2358,63 +1946,19 @@ "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], - - "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], - - "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], - - "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], - - "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], - - "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="], "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - - "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], - - "prettier-plugin-astro": ["prettier-plugin-astro@0.14.1", "", { "dependencies": { "@astrojs/compiler": "^2.9.1", "prettier": "^3.0.0", "sass-formatter": "^0.7.6" } }, "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw=="], - - "prettier-plugin-jsdoc": ["prettier-plugin-jsdoc@1.8.1", "", { "dependencies": { "binary-searching": "^2.0.5", "comment-parser": "^1.4.0", "mdast-util-from-markdown": "^2.0.0" }, "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-XuMqBWTc3b/8eCOe+OlZlFy9Z413a7WOmF4i5hDGtjbtIFOdvRrVtGjXR2Feye3TrLWhkkkHheNXPTyYKxw3nA=="], + "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], - "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], - "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - - "prosemirror-changeset": ["prosemirror-changeset@2.4.1", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw=="], - - "prosemirror-commands": ["prosemirror-commands@1.7.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="], - - "prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="], - - "prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.1", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw=="], - - "prosemirror-history": ["prosemirror-history@1.5.0", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="], - - "prosemirror-inputrules": ["prosemirror-inputrules@1.5.1", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="], - - "prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="], - - "prosemirror-model": ["prosemirror-model@1.25.7", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug=="], - - "prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="], - - "prosemirror-state": ["prosemirror-state@1.4.4", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="], - - "prosemirror-tables": ["prosemirror-tables@1.8.5", "", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="], - - "prosemirror-transform": ["prosemirror-transform@1.12.0", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w=="], - - "prosemirror-view": ["prosemirror-view@1.41.8", "", { "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="], @@ -2422,19 +1966,17 @@ "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], - "query-string": ["query-string@9.4.0", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-ivvWyHqU9K1Log4hJFhqVIIMoEi0nzmlRhvk2pPcTuQH/Y0K5iTTMxEx7R0PRHD2Z1hMVbWnjfsEWbIKIK+3IA=="], + "query-string": ["query-string@9.4.1", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-lSyJeN3RuaG7DZGWThtYRhk96+kEyZ/+doZpERuWbjeFL+Ok3vEat/swU498rAI0NcVt5/RJp8UDuLz7FckxrA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -2466,27 +2008,25 @@ "react-avatar-editor": ["react-avatar-editor@15.1.0", "", { "peerDependencies": { "react": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Zto7u9l6Wd5LPPtjeFJ+7uwoT4bs01OSgkN2kxD18lWl8IiZ0GY3nWCbKPx4qIU7Au1vENsMJm19rfVWHHayaQ=="], - "react-colorful": ["react-colorful@5.7.0", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg=="], + "react-colorful": ["react-colorful@5.8.0", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng=="], "react-day-picker": ["react-day-picker@10.0.1", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w=="], "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], - "react-draggable": ["react-draggable@4.6.0", "", { "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA=="], + "react-draggable": ["react-draggable@4.7.0", "", { "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug=="], - "react-dropzone": ["react-dropzone@14.4.1", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g=="], + "react-dropzone": ["react-dropzone@12.1.0", "", { "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8" } }, "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog=="], "react-error-boundary": ["react-error-boundary@6.1.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng=="], "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], - "react-fireworks": ["react-fireworks@1.0.4", "", {}, "sha512-jj1a+HTicB4pR6g2lqhVyAox0GTE0TOrZK2XaJFRYOwltgQWeYErZxnvU9+zH/blY+Hpmu9IKyb39OD3KcCMJw=="], - - "react-hook-form": ["react-hook-form@7.80.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg=="], + "react-hook-form": ["react-hook-form@7.82.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Zw/uFZ2dO+02GHlBn7JFGn8kZJ7LdM33B/0BXOovzFay+CMhf94JMw5BVu+F1tVkUKjNvBuaE3fz5BJhga10Tg=="], - "react-hotkeys-hook": ["react-hotkeys-hook@5.3.2", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-DDDy9xK6mbTQ6aPlQvIl0dA/a90T/AWml4Rm21JXFDLlRHalIg4/Rv3equUQYs5xPTWq+oEl6RD7mi/nBpU3Uw=="], + "react-hotkeys-hook": ["react-hotkeys-hook@5.3.3", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-aswgyWUnE25hmhzHTfKDmKzsaSE5DJ4LKaU/o6rQSXkDd/1Bh9TfAFQbHkf6fLy11HvlYkp+cDDarGdhmCDhoQ=="], - "react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="], + "react-i18next": ["react-i18next@17.0.10", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg=="], "react-icons": ["react-icons@5.7.0", "", { "peerDependencies": { "react": "*" } }, "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw=="], @@ -2504,39 +2044,21 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - "react-resizable": ["react-resizable@3.2.0", "", { "dependencies": { "prop-types": "15.x", "react-draggable": "^4.5.0" }, "peerDependencies": { "react": ">= 16.3", "react-dom": ">= 16.3" } }, "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ=="], - - "react-resizable-panels": ["react-resizable-panels@4.12.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-t/Gp57qSCxGQ52ckhz+8lM7dnuymeU95TEzl2U203qEbGkSLHrtm7US2/ANzq/zOlja3CwPTAfCDuh1unv9mfw=="], + "react-resizable-panels": ["react-resizable-panels@4.12.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q=="], "react-rnd": ["react-rnd@10.5.3", "", { "dependencies": { "re-resizable": "^6.11.2", "react-draggable": "^4.5.0", "tslib": "2.6.2" }, "peerDependencies": { "react": ">=16.3.0", "react-dom": ">=16.3.0" } }, "sha512-s/sIT3pGZnQ+57egijkTp9mizjIWrJz68Pq6yd+F/wniFY3IriML18dUXnQe/HP9uMiJ+9MAp44hljG99fZu6Q=="], - "react-router": ["react-router@6.30.4", "", { "dependencies": { "@remix-run/router": "1.23.3" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA=="], - - "react-router-dom": ["react-router-dom@6.30.4", "", { "dependencies": { "@remix-run/router": "1.23.3", "react-router": "6.30.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q=="], - "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "react-telegram-login": ["react-telegram-login@1.1.2", "", { "dependencies": { "react": "^16.13.1" } }, "sha512-pDP+bvfaklWgnK5O6yvZnIwgky0nnYUU6Zhk0EjdMSkPsLQoOzZRsXIoZnbxyBXhi7346bsxMH+EwwJPTxClDw=="], - - "react-template": ["react-template@workspace:classic"], - - "react-toastify": ["react-toastify@9.1.3", "", { "dependencies": { "clsx": "^1.1.1" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg=="], - "react-top-loading-bar": ["react-top-loading-bar@3.0.2", "", { "peerDependencies": { "react": "^16 || ^17 || ^18 || ^19" } }, "sha512-hW0CHrHqKdBOBsVhms73ka0rgb9/aoiRfqo7jiS1vwIYDK7VkyMj52ypo5ewhshTQGHKE6mRvR99GZBZ+FuM/Q=="], - "react-turnstile": ["react-turnstile@1.1.5", "", { "peerDependencies": { "react": ">= 16.13.1", "react-dom": ">= 16.13.1" } }, "sha512-VTL5OeHAatzCEVQxAZox70/TPmhKxEbNgtr++dg+8zm9QrWKuoU9E0+7gqmycOSCDZuJFzvMMLKQb5PVUPLV6w=="], - - "react-window": ["react-window@1.8.11", "", { "dependencies": { "@babel/runtime": "^7.0.0", "memoize-one": ">=3.1.1 <6" }, "peerDependencies": { "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ=="], - - "react-zoom-pan-pinch": ["react-zoom-pan-pinch@3.7.0", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA=="], - - "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + "react-zoom-pan-pinch": ["react-zoom-pan-pinch@4.0.3", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-N2Hi6L78fFmhRra+ORpFSW7WST5x6kxpOPplIvtB0b7b+U2anpo1z1wLgaWRPS2kUSqcraRG+JgBCIlDJnqqAg=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "recast": ["recast@0.23.12", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA=="], "recharts": ["recharts@3.9.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WMcwlXcB7l+BbxiEdyClkG+1sxrMHNZpzT577LEvU4+rXPd8oTAy1wXk72hnk2KOOmxuLvw3z5DtXT7HEAydtg=="], @@ -2560,8 +2082,6 @@ "rehype-github-alerts": ["rehype-github-alerts@4.2.0", "", { "dependencies": { "@primer/octicons": "^19.20.0", "hast-util-from-html": "^2.0.3", "hast-util-is-element": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-6di6kEu9WUHKLKrkKG2xX6AOuaCMGghg0Wq7MEuM/jBYUPVIq6PJpMe00dxMfU+/YSBtDXhffpDimgDi+BObIQ=="], - "rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="], - "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], @@ -2570,7 +2090,7 @@ "remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="], - "remark-cjk-friendly": ["remark-cjk-friendly@2.0.1", "", { "dependencies": { "micromark-extension-cjk-friendly": "2.0.1" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-6WwkoQyZf/4j5k53zdFYrR8Ca+UVn992jXdLUSBDZR4eBpFhKyVxmA4gUHra/5fesjGIxrDhHesNr/sVoiiysA=="], + "remark-cjk-friendly": ["remark-cjk-friendly@2.3.1", "", { "dependencies": { "mdast-util-to-markdown-cjk-friendly": "1.0.0", "micromark-extension-cjk-friendly": "2.0.1" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-f+pKZRxCRwNEGFBKNRAZAqU91GIK1SAo3ZyFHWRUgC9zcxRR0BXKd6YwqgSsxtW0rNpUDtONj7H5nje2WL3fcA=="], "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], @@ -2606,39 +2126,27 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], - "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], - "run-async": ["run-async@4.0.6", "", {}, "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ=="], - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], - "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], - - "s.color": ["s.color@0.0.15", "", {}, "sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sass-formatter": ["sass-formatter@0.7.9", "", { "dependencies": { "suf-log": "^2.5.3" } }, "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "screenfull": ["screenfull@5.2.0", "", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="], - "scroll-into-view-if-needed": ["scroll-into-view-if-needed@2.2.31", "", { "dependencies": { "compute-scroll-into-view": "^1.0.20" } }, "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA=="], + "scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2646,9 +2154,9 @@ "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], + "seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="], - "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], + "seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], @@ -2656,7 +2164,7 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shadcn": ["shadcn@4.12.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "kleur": "^4.1.5", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "undici": "^7.27.2", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-o781ieQziCnXH2FKsEqxp1fnbHdbgAPO9inTSPeZ59hQfsZXuMGp3ul8oFSV5KQS4nbUK9b+DrDE6C7OvfKKQQ=="], + "shadcn": ["shadcn@4.13.1", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "kleur": "^4.1.5", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "undici": "^7.27.2", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-pSNPND8mVWGBytdd8l4Cksg7MyRZOsv28HpvNCtFtLwZoxLSGM6v3NMUpmpbOaIoreopS/UOpQvnCyttOHVLAQ=="], "shapefile": ["shapefile@0.6.6", "", { "dependencies": { "array-source": "0.0", "commander": "2", "path-source": "0.1", "slice-source": "0.4", "stream-source": "0.3", "text-encoding": "^0.6.4" }, "bin": { "dbf2json": "bin/dbf2json", "shp2json": "bin/shp2json" } }, "sha512-rLGSWeK2ufzCVx05wYd+xrWnOOdSV7xNUW5/XFgx3Bc02hBkpMlrd2F1dDII7/jhWzv0MSyBFh5uJIy9hLdfuw=="], @@ -2664,11 +2172,9 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shiki": ["shiki@4.3.0", "", { "dependencies": { "@shikijs/core": "4.3.0", "@shikijs/engine-javascript": "4.3.0", "@shikijs/engine-oniguruma": "4.3.0", "@shikijs/langs": "4.3.0", "@shikijs/themes": "4.3.0", "@shikijs/types": "4.3.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NKKjWzR6LIGL3sXBrWDw9sDS9cxx42/DkysaNqJEeOWE8Kix5gpak0bc00OfDVEO4oyXSyz8+aRaqKoBD1yo7A=="], - - "shiki-stream": ["shiki-stream@0.1.4", "", { "dependencies": { "@shikijs/core": "^3.0.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-4pz6JGSDmVTTkPJ/ueixHkFAXY4ySCc+unvCaDZV7hqq/sdJZirRxgIXSuNSKgiFlGTgRR97sdu2R8K55sPsrw=="], + "shiki": ["shiki@4.3.1", "", { "dependencies": { "@shikijs/core": "4.3.1", "@shikijs/engine-javascript": "4.3.1", "@shikijs/engine-oniguruma": "4.3.1", "@shikijs/langs": "4.3.1", "@shikijs/themes": "4.3.1", "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -2678,7 +2184,7 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "simple-statistics": ["simple-statistics@7.8.9", "", {}, "sha512-YT6MLqYsz7y1rQZOLFlOCCgSRpCi6bqY417yhoOLI7aVoBi29dD39EPrOE03W9DY25H0J0jizVsHZnkLzyGJFg=="], + "simple-statistics": ["simple-statistics@7.9.3", "", {}, "sha512-WXpxUfo7BJCRpyl4besiuMV7wNj9xiPIq7IKmUQO4upIaF8pK2AXwhjttHN5L8KXZrLkGMCHGHq4p+pJXiIahQ=="], "simplify-geojson": ["simplify-geojson@1.0.5", "", { "dependencies": { "concat-stream": "~1.4.1", "minimist": "1.2.6", "simplify-geometry": "0.0.2" }, "bin": { "simplify-geojson": "cli.js" } }, "sha512-02l1W4UipP5ivNVq6kX15mAzCRIV1oI3tz0FUEyOsNiv1ltuFDjbNhO+nbv/xhbDEtKqWLYuzpWhUsJrjR/ypA=="], @@ -2688,7 +2194,7 @@ "slice-source": ["slice-source@0.4.1", "", {}, "sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg=="], - "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "smol-toml": ["smol-toml@1.7.0", "", {}, "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ=="], "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], @@ -2708,7 +2214,7 @@ "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], - "stream-markdown-parser": ["stream-markdown-parser@1.0.9", "", { "dependencies": { "markdown-it-container": "^4.0.0", "markdown-it-footnote": "^4.0.0", "markdown-it-ins": "^4.0.0", "markdown-it-mark": "^4.0.0", "markdown-it-sub": "^2.0.0", "markdown-it-sup": "^2.0.0", "markdown-it-task-checkbox": "^1.0.6", "markdown-it-ts": "^1.0.2" } }, "sha512-uqf2F7DMBxc2JCWv9QyrzFNWQpzLF8zIH0BuLgA5LKv2jRbF+VJ9Dqz7LyRToJilAnns7PTud5XbsaSmIcjZ6Q=="], + "stream-markdown-parser": ["stream-markdown-parser@1.1.3", "", { "dependencies": { "markdown-it-container": "^4.0.0", "markdown-it-footnote": "^4.0.0", "markdown-it-ins": "^4.0.0", "markdown-it-mark": "^4.0.0", "markdown-it-sub": "^2.0.0", "markdown-it-sup": "^2.0.0", "markdown-it-task-checkbox": "^1.0.6", "markdown-it-ts": "^1.0.4" } }, "sha512-tge6aKbOGU36vBLeeroHGBAJ7qiNcsTYyvmgqevVN0+5kMiLOjAyeDmv3PQkniTMbgOsswVHN9izmgmSw5bsBQ=="], "stream-source": ["stream-source@0.3.5", "", {}, "sha512-ZuEDP9sgjiAwUVoDModftG0JtYiLUV8K4ljYD1VyUMRWtbVf92474o4kuuul43iZ8t/hRuiDAx1dIJSvirrK/g=="], @@ -2722,7 +2228,7 @@ "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], @@ -2738,32 +2244,22 @@ "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], - "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], - - "suf-log": ["suf-log@2.5.3", "", { "dependencies": { "s.color": "0.0.15" } }, "sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow=="], - - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="], + "swr": ["swr@2.4.2", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw=="], + + "systeminformation": ["systeminformation@5.33.0", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-0LYSL01CCbjVeJG7iXI8fUCFU76zMjzbHd/EU3or4QpSFYCLMgslR11prwHuA3siz5jmOkqoLhjgOyDRmXBKmA=="], - "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], + "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="], + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "text-encoding": ["text-encoding@0.6.4", "", {}, "sha512-hJnc6Qg3dWoOMkqP53F0dzRIgtmsAge09kxUIqGrEUS4qr5rWLckGYaQAVr+opBrIMRErGgy6f5aPnyPpyGRfg=="], - "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], - - "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], - - "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], - "throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -2790,9 +2286,7 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], - "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], - - "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], "ts-md5": ["ts-md5@2.0.1", "", {}, "sha512-yF35FCoEOFBzOclSkMNEUbFQZuv89KEQ+5Xz03HrMSGUGB1+r+El+JiGOFwsP4p9RFNzwlrydYoTLvPOuICl9w=="], @@ -2804,19 +2298,13 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - - "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], - "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], - "typescript": ["typescript@4.4.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-gzP+t5W4hdy4c+68bfcv0t400HVJMMd2+H9B7gae1nQlBzCqvrXX+6GL/b3GAgyTH966pzrZ70/fRjwAtZksSQ=="], - "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], - "unbash": ["unbash@4.0.2", "", {}, "sha512-8gwNZ29+0/3zmXw7ToIHZtg6wK37xnniRUdBt7B27xZxaxfgR5tGMaGHT0t0dLtBV9fXE7zurh0s6Z1DHVjfWg=="], + "unbash": ["unbash@4.0.3", "", {}, "sha512-3cudTErfToSc4Ggv8XGXVNVli/xHKUtUZvaY5UVwhOcUPbQGz7PeaEnT/SAVgNziZtX67KEN9swMUYkLghxA1w=="], "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], @@ -2846,18 +2334,14 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], + "unplugin": ["unplugin@3.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.4", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], - "use-debounce": ["use-debounce@10.1.1", "", { "peerDependencies": { "react": "*" } }, "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ=="], - "use-merge-value": ["use-merge-value@1.2.0", "", { "peerDependencies": { "react": ">= 16.x" } }, "sha512-DXgG0kkgJN45TcyoXL49vJnn55LehnrmoHc7MbKi+QDBvr8dsesqws8UlyIWGHMR+JXgxc1nvY+jDGMlycsUcw=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], @@ -2868,8 +2352,6 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], - "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "v8n": ["v8n@1.5.1", "", {}, "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A=="], @@ -2888,7 +2370,7 @@ "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], - "virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="], + "virtua": ["virtua@0.49.3", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-k1Yn988Vz/L40uDtEWPjfdVo15Suumh4tU4/z5Srs0elNcU9DgBskqdh3llpHyAlXrCeHWTfcclYvY1uU4MmIg=="], "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], @@ -2902,8 +2384,6 @@ "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], @@ -2912,9 +2392,7 @@ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "yocto-spinner": ["yocto-spinner@1.2.0", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw=="], + "yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="], "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], @@ -2926,27 +2404,11 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@codemirror/autocomplete/@codemirror/view": ["@codemirror/view@6.43.3", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng=="], - - "@codemirror/lang-html/@codemirror/view": ["@codemirror/view@6.43.3", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng=="], - - "@codemirror/lang-javascript/@codemirror/view": ["@codemirror/view@6.43.3", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng=="], - - "@codemirror/lang-markdown/@codemirror/view": ["@codemirror/view@6.43.3", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng=="], - - "@codemirror/language/@codemirror/view": ["@codemirror/view@6.43.3", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng=="], - - "@codemirror/lint/@codemirror/view": ["@codemirror/view@6.43.3", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng=="], - "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], - "@douyinfe/semi-foundation/date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="], - - "@douyinfe/semi-ui/date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="], - - "@emoji-mart/react/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "@dotenvx/dotenvx/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "@emotion/babel-plugin/@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], @@ -2962,69 +2424,35 @@ "@emotion/serialize/@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], - "@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "@lobehub/fluent-emoji/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], "@lobehub/icons/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="], - "@lobehub/ui/@base-ui/react": ["@base-ui/react@1.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.2.9", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A=="], - - "@lobehub/ui/@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], - - "@lobehub/ui/@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="], - - "@lobehub/ui/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], - - "@lobehub/ui/lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], - - "@lobehub/ui/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], - - "@lobehub/ui/motion": ["motion@12.40.0", "", { "dependencies": { "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA=="], - - "@lobehub/ui/shiki": ["shiki@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="], - - "@lobehub/ui/uuid": ["uuid@13.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="], - "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], - "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], - - "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], - - "@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], - - "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@pierre/diffs/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@rc-component/dialog/@rc-component/portal": ["@rc-component/portal@2.2.1", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA=="], - "@rc-component/dialog/@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="], + "@rc-component/drawer/@rc-component/portal": ["@rc-component/portal@2.2.1", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA=="], - "@rc-component/drawer/@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="], + "@rc-component/image/@rc-component/portal": ["@rc-component/portal@2.2.1", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA=="], - "@rc-component/image/@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="], + "@rc-component/tour/@rc-component/portal": ["@rc-component/portal@2.2.1", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA=="], - "@rc-component/tour/@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="], + "@rc-component/trigger/@rc-component/portal": ["@rc-component/portal@2.2.1", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA=="], - "@rc-component/trigger/@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="], + "@rc-component/util/react-is": ["react-is@19.2.7", "", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="], - "@reduxjs/toolkit/reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "@rc-component/virtual-list/@babel/runtime": ["@babel/runtime@8.0.0", "", {}, "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw=="], - "@rspack/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@rspack/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], - "@shikijs/transformers/@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="], - - "@shikijs/transformers/@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="], - - "@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "@rspack/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], @@ -3032,72 +2460,14 @@ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tailwindcss/webpack/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], - - "@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.171.14", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-Mo3hwx0qB0cJsVYGDjG0+Ouf7VV74h/vsoDMGztdlyzDanp4gBA2s7IVvm6hFrmQM6GpD9F0Z7SqD7OldfLE7g=="], - - "@tanstack/router-generator/@tanstack/router-core": ["@tanstack/router-core@1.171.14", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-Mo3hwx0qB0cJsVYGDjG0+Ouf7VV74h/vsoDMGztdlyzDanp4gBA2s7IVvm6hFrmQM6GpD9F0Z7SqD7OldfLE7g=="], - - "@tanstack/router-plugin/@tanstack/router-core": ["@tanstack/router-core@1.171.14", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-Mo3hwx0qB0cJsVYGDjG0+Ouf7VV74h/vsoDMGztdlyzDanp4gBA2s7IVvm6hFrmQM6GpD9F0Z7SqD7OldfLE7g=="], - - "@ts-morph/common/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "@visactor/vchart-semi-theme/@visactor/vchart": ["@visactor/vchart@1.8.11", "", { "dependencies": { "@visactor/vdataset": "~0.17.3", "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-hierarchy": "0.10.11", "@visactor/vgrammar-projection": "0.10.11", "@visactor/vgrammar-sankey": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vgrammar-wordcloud": "0.10.11", "@visactor/vgrammar-wordcloud-shape": "0.10.11", "@visactor/vrender-components": "0.17.17", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3", "@visactor/vutils-extension": "1.8.11" } }, "sha512-RdQ822J02GgAQNXvO1LiT0T3O6FjdgPdcm9hVBFyrpBBmuI8MH02IE7Y1kGe9NiFTH4tDwP0ixRgBmqNSGSLZQ=="], - - "@visactor/vchart-theme-utils/@visactor/vchart": ["@visactor/vchart@1.8.11", "", { "dependencies": { "@visactor/vdataset": "~0.17.3", "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-hierarchy": "0.10.11", "@visactor/vgrammar-projection": "0.10.11", "@visactor/vgrammar-sankey": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vgrammar-wordcloud": "0.10.11", "@visactor/vgrammar-wordcloud-shape": "0.10.11", "@visactor/vrender-components": "0.17.17", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3", "@visactor/vutils-extension": "1.8.11" } }, "sha512-RdQ822J02GgAQNXvO1LiT0T3O6FjdgPdcm9hVBFyrpBBmuI8MH02IE7Y1kGe9NiFTH4tDwP0ixRgBmqNSGSLZQ=="], - "@visactor/vdataset/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - "@visactor/vgrammar-coordinate/@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], - - "@visactor/vgrammar-core/@visactor/vdataset": ["@visactor/vdataset@0.17.5", "", { "dependencies": { "@turf/flatten": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/rewind": "^6.5.0", "@visactor/vutils": "0.17.5", "d3-dsv": "^2.0.0", "d3-geo": "^1.12.1", "d3-hexbin": "^0.2.2", "d3-hierarchy": "^3.1.1", "eventemitter3": "^4.0.7", "geobuf": "^3.0.1", "geojson-dissolve": "^3.1.0", "path-browserify": "^1.0.1", "pbf": "^3.2.1", "point-at-length": "^1.1.0", "simple-statistics": "^7.7.3", "simplify-geojson": "^1.0.4", "topojson-client": "^3.1.0" } }, "sha512-zVBdLWHWrhldGc8JDjSYF9lvpFT4ZEFQDB0b6yvfSiHzHKHiSco+rWmUFvA7r4ObT6j2QWF1vZAV9To8Ml4vHw=="], - - "@visactor/vgrammar-core/@visactor/vrender-components": ["@visactor/vrender-components@0.17.17", "", { "dependencies": { "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-7gYFQrozvBkyGF7s/JHXdWDZnATzymxzug63CZd4EB7A0OXKatVDImXRePqwzlPD3QamF7QMVWn0CuIx3gQ2gA=="], - - "@visactor/vgrammar-core/@visactor/vrender-core": ["@visactor/vrender-core@0.17.17", "", { "dependencies": { "@visactor/vutils": "~0.17.3", "color-convert": "2.0.1" } }, "sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ=="], - - "@visactor/vgrammar-core/@visactor/vrender-kits": ["@visactor/vrender-kits@0.17.17", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "0.17.17", "@visactor/vutils": "~0.17.3", "roughjs": "4.5.2" } }, "sha512-noRP1hAHvPCv36nf2P6sZ930Tk+dJ8jpPWIUm1cFYmUNdcumgIS8Cug0RyeZ+saSqVt5FDTwIwifhOqupw5Zaw=="], - - "@visactor/vgrammar-core/@visactor/vscale": ["@visactor/vscale@0.17.5", "", { "dependencies": { "@visactor/vutils": "0.17.5" } }, "sha512-2dkS1IlAJ/IdTp8JElbctOOv6lkHKBKPDm8KvwBo0NuGWQeYAebSeyN3QCdwKbj76gMlCub4zc+xWrS5YiA2zA=="], - - "@visactor/vgrammar-core/@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], - - "@visactor/vgrammar-hierarchy/@visactor/vrender-core": ["@visactor/vrender-core@0.17.17", "", { "dependencies": { "@visactor/vutils": "~0.17.3", "color-convert": "2.0.1" } }, "sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ=="], - - "@visactor/vgrammar-hierarchy/@visactor/vrender-kits": ["@visactor/vrender-kits@0.17.17", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "0.17.17", "@visactor/vutils": "~0.17.3", "roughjs": "4.5.2" } }, "sha512-noRP1hAHvPCv36nf2P6sZ930Tk+dJ8jpPWIUm1cFYmUNdcumgIS8Cug0RyeZ+saSqVt5FDTwIwifhOqupw5Zaw=="], - - "@visactor/vgrammar-hierarchy/@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], - - "@visactor/vgrammar-projection/@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], - - "@visactor/vgrammar-sankey/@visactor/vrender-core": ["@visactor/vrender-core@0.17.17", "", { "dependencies": { "@visactor/vutils": "~0.17.3", "color-convert": "2.0.1" } }, "sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ=="], - - "@visactor/vgrammar-sankey/@visactor/vrender-kits": ["@visactor/vrender-kits@0.17.17", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "0.17.17", "@visactor/vutils": "~0.17.3", "roughjs": "4.5.2" } }, "sha512-noRP1hAHvPCv36nf2P6sZ930Tk+dJ8jpPWIUm1cFYmUNdcumgIS8Cug0RyeZ+saSqVt5FDTwIwifhOqupw5Zaw=="], - - "@visactor/vgrammar-sankey/@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], - - "@visactor/vgrammar-util/@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], - - "@visactor/vgrammar-wordcloud/@visactor/vrender-core": ["@visactor/vrender-core@0.17.17", "", { "dependencies": { "@visactor/vutils": "~0.17.3", "color-convert": "2.0.1" } }, "sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ=="], - - "@visactor/vgrammar-wordcloud/@visactor/vrender-kits": ["@visactor/vrender-kits@0.17.17", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "0.17.17", "@visactor/vutils": "~0.17.3", "roughjs": "4.5.2" } }, "sha512-noRP1hAHvPCv36nf2P6sZ930Tk+dJ8jpPWIUm1cFYmUNdcumgIS8Cug0RyeZ+saSqVt5FDTwIwifhOqupw5Zaw=="], - - "@visactor/vgrammar-wordcloud/@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], - - "@visactor/vgrammar-wordcloud-shape/@visactor/vrender-core": ["@visactor/vrender-core@0.17.17", "", { "dependencies": { "@visactor/vutils": "~0.17.3", "color-convert": "2.0.1" } }, "sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ=="], - - "@visactor/vgrammar-wordcloud-shape/@visactor/vrender-kits": ["@visactor/vrender-kits@0.17.17", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "0.17.17", "@visactor/vutils": "~0.17.3", "roughjs": "4.5.2" } }, "sha512-noRP1hAHvPCv36nf2P6sZ930Tk+dJ8jpPWIUm1cFYmUNdcumgIS8Cug0RyeZ+saSqVt5FDTwIwifhOqupw5Zaw=="], - - "@visactor/vgrammar-wordcloud-shape/@visactor/vscale": ["@visactor/vscale@0.17.5", "", { "dependencies": { "@visactor/vutils": "0.17.5" } }, "sha512-2dkS1IlAJ/IdTp8JElbctOOv6lkHKBKPDm8KvwBo0NuGWQeYAebSeyN3QCdwKbj76gMlCub4zc+xWrS5YiA2zA=="], - - "@visactor/vgrammar-wordcloud-shape/@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], - "@visactor/vlayouts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], "@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -3106,15 +2476,15 @@ "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "babel-plugin-macros/cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], - "antd/scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], - "babel-plugin-macros/cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], + "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], - "cosmiconfig/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "conf/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -3132,46 +2502,34 @@ "d3-geo/d3-array": ["d3-array@1.2.4", "", {}, "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="], - "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + "d3-sankey/d3-array": ["d3-array@1.2.4", "", {}, "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="], "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], + + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "extend-shallow/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "geojson-dissolve/@turf/meta": ["@turf/meta@3.14.0", "", {}, "sha512-OtXqLQuR9hlQ/HkAF/OdzRea7E0eZK1ay8y8CBXkoO2R6v34CsDrWYLMSo0ZzMsaQDpKo76NPP2GGo+PyG1cSg=="], - "geojson-flatten/minimist": ["minimist@1.2.0", "", {}, "sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw=="], - - "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "i18next-cli/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "i18next-cli/ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="], - - "i18next-cli/react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "leva/react-dropzone": ["react-dropzone@12.1.0", "", { "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8" } }, "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog=="], - "leva/zustand": ["zustand@3.7.2", "", { "peerDependencies": { "react": ">=16.8" }, "optionalPeers": ["react"] }, "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA=="], - "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "mermaid/dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="], - "mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], @@ -3182,19 +2540,13 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "ora/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "path-scurry/lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], - - "postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], - - "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + "postcss/nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], @@ -3202,38 +2554,12 @@ "rc-menu/@rc-component/trigger": ["@rc-component/trigger@2.3.1", "", { "dependencies": { "@babel/runtime": "^7.23.2", "@rc-component/portal": "^1.1.0", "classnames": "^2.3.2", "rc-motion": "^2.0.0", "rc-resize-observer": "^1.3.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A=="], - "react-i18next/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "react-rnd/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], - "react-telegram-login/react": ["react@16.14.0", "", { "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2" } }, "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g=="], - - "react-template/@visactor/react-vchart": ["@visactor/react-vchart@1.8.11", "", { "dependencies": { "@visactor/vchart": "1.8.11", "@visactor/vgrammar-core": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vutils": "~0.17.3", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-wHnCex9gOpnttTtSu04ozKJhTveUk8Ln2KX/7PZyCJxqlXq+eWvW4zvM6Ja8T8kGXfXtFYVVNh9zBMQ7y2T/Sw=="], - - "react-template/@visactor/vchart": ["@visactor/vchart@1.8.11", "", { "dependencies": { "@visactor/vdataset": "~0.17.3", "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-hierarchy": "0.10.11", "@visactor/vgrammar-projection": "0.10.11", "@visactor/vgrammar-sankey": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vgrammar-wordcloud": "0.10.11", "@visactor/vgrammar-wordcloud-shape": "0.10.11", "@visactor/vrender-components": "0.17.17", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3", "@visactor/vutils-extension": "1.8.11" } }, "sha512-RdQ822J02GgAQNXvO1LiT0T3O6FjdgPdcm9hVBFyrpBBmuI8MH02IE7Y1kGe9NiFTH4tDwP0ixRgBmqNSGSLZQ=="], - - "react-template/i18next": ["i18next@23.16.8", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg=="], - - "react-template/i18next-browser-languagedetector": ["i18next-browser-languagedetector@7.2.2", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-6b7r75uIJDWCcCflmbof+sJ94k9UQO4X0YR62oUfqGI/GjCLVzlCwu8TFdRZIqVLzWbzNcmkmhfqKEr4TLz4HQ=="], - - "react-template/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], - - "react-template/lucide-react": ["lucide-react@0.511.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="], - - "react-template/marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="], - - "react-template/react-i18next": ["react-i18next@13.5.0", "", { "dependencies": { "@babel/runtime": "^7.22.5", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA=="], - - "react-template/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], - - "react-toastify/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], - "rehype-katex/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "set-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], @@ -3242,18 +2568,10 @@ "shapefile/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "shiki-stream/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], - "simplify-geojson/concat-stream": ["concat-stream@1.4.11", "", { "dependencies": { "inherits": "~2.0.1", "readable-stream": "~1.1.9", "typedarray": "~0.0.5" } }, "sha512-X3JMh8+4je3U1cQpG87+f9lXHDrqcb2MVLg9L7o8b1UZ0DzhRrUpdn65ttzu10PpJPPI3MQNkis+oha6TSA9Mw=="], - "simplify-geojson/minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="], - "split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], - "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "topojson-server/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -3262,6 +2580,8 @@ "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -3274,112 +2594,10 @@ "@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "@lobehub/ui/@base-ui/react/@base-ui/utils": ["@base-ui/utils@0.2.9", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw=="], - - "@lobehub/ui/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="], - - "@lobehub/ui/@shikijs/core/@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="], - - "@lobehub/ui/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - - "@lobehub/ui/motion/framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="], - - "@lobehub/ui/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="], - - "@lobehub/ui/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="], - - "@lobehub/ui/shiki/@shikijs/langs": ["@shikijs/langs@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="], - - "@lobehub/ui/shiki/@shikijs/themes": ["@shikijs/themes@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="], - - "@lobehub/ui/shiki/@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="], - - "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], - - "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - - "@pierre/diffs/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], - - "@pierre/diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], - - "@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], - - "@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], - - "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], - - "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - - "@rspack/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - - "@shikijs/transformers/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="], - - "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - - "@visactor/vchart-semi-theme/@visactor/vchart/@visactor/vdataset": ["@visactor/vdataset@0.17.5", "", { "dependencies": { "@turf/flatten": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/rewind": "^6.5.0", "@visactor/vutils": "0.17.5", "d3-dsv": "^2.0.0", "d3-geo": "^1.12.1", "d3-hexbin": "^0.2.2", "d3-hierarchy": "^3.1.1", "eventemitter3": "^4.0.7", "geobuf": "^3.0.1", "geojson-dissolve": "^3.1.0", "path-browserify": "^1.0.1", "pbf": "^3.2.1", "point-at-length": "^1.1.0", "simple-statistics": "^7.7.3", "simplify-geojson": "^1.0.4", "topojson-client": "^3.1.0" } }, "sha512-zVBdLWHWrhldGc8JDjSYF9lvpFT4ZEFQDB0b6yvfSiHzHKHiSco+rWmUFvA7r4ObT6j2QWF1vZAV9To8Ml4vHw=="], - - "@visactor/vchart-semi-theme/@visactor/vchart/@visactor/vrender-components": ["@visactor/vrender-components@0.17.17", "", { "dependencies": { "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-7gYFQrozvBkyGF7s/JHXdWDZnATzymxzug63CZd4EB7A0OXKatVDImXRePqwzlPD3QamF7QMVWn0CuIx3gQ2gA=="], - - "@visactor/vchart-semi-theme/@visactor/vchart/@visactor/vrender-core": ["@visactor/vrender-core@0.17.17", "", { "dependencies": { "@visactor/vutils": "~0.17.3", "color-convert": "2.0.1" } }, "sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ=="], - - "@visactor/vchart-semi-theme/@visactor/vchart/@visactor/vrender-kits": ["@visactor/vrender-kits@0.17.17", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "0.17.17", "@visactor/vutils": "~0.17.3", "roughjs": "4.5.2" } }, "sha512-noRP1hAHvPCv36nf2P6sZ930Tk+dJ8jpPWIUm1cFYmUNdcumgIS8Cug0RyeZ+saSqVt5FDTwIwifhOqupw5Zaw=="], - - "@visactor/vchart-semi-theme/@visactor/vchart/@visactor/vscale": ["@visactor/vscale@0.17.5", "", { "dependencies": { "@visactor/vutils": "0.17.5" } }, "sha512-2dkS1IlAJ/IdTp8JElbctOOv6lkHKBKPDm8KvwBo0NuGWQeYAebSeyN3QCdwKbj76gMlCub4zc+xWrS5YiA2zA=="], - - "@visactor/vchart-semi-theme/@visactor/vchart/@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], - - "@visactor/vchart-semi-theme/@visactor/vchart/@visactor/vutils-extension": ["@visactor/vutils-extension@1.8.11", "", { "dependencies": { "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-Hknzpy3+xh4sdL0iSn5N93BHiMJF4FdwSwhHYEibRpriZmWKG6wBxsJ0Bll4d7oS4f+svxt8Sg2vRYKzQEcIxQ=="], - - "@visactor/vchart-theme-utils/@visactor/vchart/@visactor/vdataset": ["@visactor/vdataset@0.17.5", "", { "dependencies": { "@turf/flatten": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/rewind": "^6.5.0", "@visactor/vutils": "0.17.5", "d3-dsv": "^2.0.0", "d3-geo": "^1.12.1", "d3-hexbin": "^0.2.2", "d3-hierarchy": "^3.1.1", "eventemitter3": "^4.0.7", "geobuf": "^3.0.1", "geojson-dissolve": "^3.1.0", "path-browserify": "^1.0.1", "pbf": "^3.2.1", "point-at-length": "^1.1.0", "simple-statistics": "^7.7.3", "simplify-geojson": "^1.0.4", "topojson-client": "^3.1.0" } }, "sha512-zVBdLWHWrhldGc8JDjSYF9lvpFT4ZEFQDB0b6yvfSiHzHKHiSco+rWmUFvA7r4ObT6j2QWF1vZAV9To8Ml4vHw=="], - - "@visactor/vchart-theme-utils/@visactor/vchart/@visactor/vrender-components": ["@visactor/vrender-components@0.17.17", "", { "dependencies": { "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-7gYFQrozvBkyGF7s/JHXdWDZnATzymxzug63CZd4EB7A0OXKatVDImXRePqwzlPD3QamF7QMVWn0CuIx3gQ2gA=="], - - "@visactor/vchart-theme-utils/@visactor/vchart/@visactor/vrender-core": ["@visactor/vrender-core@0.17.17", "", { "dependencies": { "@visactor/vutils": "~0.17.3", "color-convert": "2.0.1" } }, "sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ=="], - - "@visactor/vchart-theme-utils/@visactor/vchart/@visactor/vrender-kits": ["@visactor/vrender-kits@0.17.17", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "0.17.17", "@visactor/vutils": "~0.17.3", "roughjs": "4.5.2" } }, "sha512-noRP1hAHvPCv36nf2P6sZ930Tk+dJ8jpPWIUm1cFYmUNdcumgIS8Cug0RyeZ+saSqVt5FDTwIwifhOqupw5Zaw=="], - - "@visactor/vchart-theme-utils/@visactor/vchart/@visactor/vscale": ["@visactor/vscale@0.17.5", "", { "dependencies": { "@visactor/vutils": "0.17.5" } }, "sha512-2dkS1IlAJ/IdTp8JElbctOOv6lkHKBKPDm8KvwBo0NuGWQeYAebSeyN3QCdwKbj76gMlCub4zc+xWrS5YiA2zA=="], - - "@visactor/vchart-theme-utils/@visactor/vchart/@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], - - "@visactor/vchart-theme-utils/@visactor/vchart/@visactor/vutils-extension": ["@visactor/vutils-extension@1.8.11", "", { "dependencies": { "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-Hknzpy3+xh4sdL0iSn5N93BHiMJF4FdwSwhHYEibRpriZmWKG6wBxsJ0Bll4d7oS4f+svxt8Sg2vRYKzQEcIxQ=="], - - "@visactor/vgrammar-coordinate/@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "@visactor/vgrammar-core/@visactor/vdataset/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "@visactor/vgrammar-core/@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], - - "@visactor/vgrammar-core/@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "@visactor/vgrammar-hierarchy/@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], - - "@visactor/vgrammar-hierarchy/@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "@visactor/vgrammar-projection/@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "@visactor/vgrammar-sankey/@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], - - "@visactor/vgrammar-sankey/@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "@visactor/vgrammar-util/@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "@visactor/vgrammar-wordcloud-shape/@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], - - "@visactor/vgrammar-wordcloud-shape/@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "@visactor/vgrammar-wordcloud/@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], - - "@visactor/vgrammar-wordcloud/@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "@dotenvx/dotenvx/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "antd/scroll-into-view-if-needed/compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], - "babel-plugin-macros/cosmiconfig/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -3390,120 +2608,30 @@ "d3-fetch/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], - "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "d3/d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "d3/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - - "i18next-cli/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - - "i18next-cli/ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "i18next-cli/ora/cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], - - "i18next-cli/ora/log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "i18next-cli/ora/stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="], - - "i18next-cli/ora/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], - - "leva/react-dropzone/file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="], + "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "ora/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "react-template/@visactor/react-vchart/@visactor/vrender-core": ["@visactor/vrender-core@0.17.17", "", { "dependencies": { "@visactor/vutils": "~0.17.3", "color-convert": "2.0.1" } }, "sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ=="], - - "react-template/@visactor/react-vchart/@visactor/vrender-kits": ["@visactor/vrender-kits@0.17.17", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "0.17.17", "@visactor/vutils": "~0.17.3", "roughjs": "4.5.2" } }, "sha512-noRP1hAHvPCv36nf2P6sZ930Tk+dJ8jpPWIUm1cFYmUNdcumgIS8Cug0RyeZ+saSqVt5FDTwIwifhOqupw5Zaw=="], - - "react-template/@visactor/react-vchart/@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], - - "react-template/@visactor/vchart/@visactor/vdataset": ["@visactor/vdataset@0.17.5", "", { "dependencies": { "@turf/flatten": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/rewind": "^6.5.0", "@visactor/vutils": "0.17.5", "d3-dsv": "^2.0.0", "d3-geo": "^1.12.1", "d3-hexbin": "^0.2.2", "d3-hierarchy": "^3.1.1", "eventemitter3": "^4.0.7", "geobuf": "^3.0.1", "geojson-dissolve": "^3.1.0", "path-browserify": "^1.0.1", "pbf": "^3.2.1", "point-at-length": "^1.1.0", "simple-statistics": "^7.7.3", "simplify-geojson": "^1.0.4", "topojson-client": "^3.1.0" } }, "sha512-zVBdLWHWrhldGc8JDjSYF9lvpFT4ZEFQDB0b6yvfSiHzHKHiSco+rWmUFvA7r4ObT6j2QWF1vZAV9To8Ml4vHw=="], - - "react-template/@visactor/vchart/@visactor/vrender-components": ["@visactor/vrender-components@0.17.17", "", { "dependencies": { "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-7gYFQrozvBkyGF7s/JHXdWDZnATzymxzug63CZd4EB7A0OXKatVDImXRePqwzlPD3QamF7QMVWn0CuIx3gQ2gA=="], - - "react-template/@visactor/vchart/@visactor/vrender-core": ["@visactor/vrender-core@0.17.17", "", { "dependencies": { "@visactor/vutils": "~0.17.3", "color-convert": "2.0.1" } }, "sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ=="], - - "react-template/@visactor/vchart/@visactor/vrender-kits": ["@visactor/vrender-kits@0.17.17", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "0.17.17", "@visactor/vutils": "~0.17.3", "roughjs": "4.5.2" } }, "sha512-noRP1hAHvPCv36nf2P6sZ930Tk+dJ8jpPWIUm1cFYmUNdcumgIS8Cug0RyeZ+saSqVt5FDTwIwifhOqupw5Zaw=="], - - "react-template/@visactor/vchart/@visactor/vscale": ["@visactor/vscale@0.17.5", "", { "dependencies": { "@visactor/vutils": "0.17.5" } }, "sha512-2dkS1IlAJ/IdTp8JElbctOOv6lkHKBKPDm8KvwBo0NuGWQeYAebSeyN3QCdwKbj76gMlCub4zc+xWrS5YiA2zA=="], - - "react-template/@visactor/vchart/@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], - - "react-template/@visactor/vchart/@visactor/vutils-extension": ["@visactor/vutils-extension@1.8.11", "", { "dependencies": { "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-Hknzpy3+xh4sdL0iSn5N93BHiMJF4FdwSwhHYEibRpriZmWKG6wBxsJ0Bll4d7oS4f+svxt8Sg2vRYKzQEcIxQ=="], - - "react-template/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - - "react-template/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], - - "react-template/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], - - "react-template/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], - "rehype-katex/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "shiki-stream/@shikijs/core/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - "simplify-geojson/concat-stream/readable-stream": ["readable-stream@1.1.14", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "simplify-geojson/concat-stream/typedarray": ["typedarray@0.0.7", "", {}, "sha512-ueeb9YybpjhivjbHP2LdFDAjbS948fGEPj+ACAMs4xCMmh72OCOMQWBQKlaN4ZNQ04yfLSDLSx1tGRIoWimObQ=="], "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "@lobehub/ui/@base-ui/react/@base-ui/utils/reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], - - "@lobehub/ui/motion/framer-motion/motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], - - "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "@visactor/vchart-semi-theme/@visactor/vchart/@visactor/vdataset/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "@visactor/vchart-semi-theme/@visactor/vchart/@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], - - "@visactor/vchart-semi-theme/@visactor/vchart/@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "@visactor/vchart-theme-utils/@visactor/vchart/@visactor/vdataset/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "@visactor/vchart-theme-utils/@visactor/vchart/@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], - - "@visactor/vchart-theme-utils/@visactor/vchart/@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "i18next-cli/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "i18next-cli/ora/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "react-template/@visactor/react-vchart/@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], - - "react-template/@visactor/react-vchart/@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "react-template/@visactor/vchart/@visactor/vdataset/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "react-template/@visactor/vchart/@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], - - "react-template/@visactor/vchart/@visactor/vutils/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "react-template/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - - "react-template/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "simplify-geojson/concat-stream/readable-stream/string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="], - - "i18next-cli/ora/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "react-template/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], } } diff --git a/web/classic/.eslintrc.cjs b/web/classic/.eslintrc.cjs deleted file mode 100644 index b1afd96f5b72..000000000000 --- a/web/classic/.eslintrc.cjs +++ /dev/null @@ -1,42 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2021: true, node: true }, - parserOptions: { - ecmaVersion: 2020, - sourceType: 'module', - ecmaFeatures: { jsx: true }, - }, - plugins: ['header', 'react-hooks'], - overrides: [ - { - files: ['**/*.{js,jsx}'], - rules: { - 'header/header': [ - 2, - 'block', - [ - '', - 'Copyright (C) 2025 QuantumNous', - '', - 'This program is free software: you can redistribute it and/or modify', - 'it under the terms of the GNU Affero General Public License as', - 'published by the Free Software Foundation, either version 3 of the', - 'License, or (at your option) any later version.', - '', - 'This program is distributed in the hope that it will be useful,', - 'but WITHOUT ANY WARRANTY; without even the implied warranty of', - 'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the', - 'GNU Affero General Public License for more details.', - '', - 'You should have received a copy of the GNU Affero General Public License', - 'along with this program. If not, see .', - '', - 'For commercial licensing, please contact support@quantumnous.com', - '', - ], - ], - 'no-multiple-empty-lines': ['error', { max: 1 }], - }, - }, - ], -}; diff --git a/web/classic/.gitignore b/web/classic/.gitignore deleted file mode 100644 index 2b5bba767be2..000000000000 --- a/web/classic/.gitignore +++ /dev/null @@ -1,26 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# production -/build - -# misc -.DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local - -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.idea -package-lock.json -yarn.lock \ No newline at end of file diff --git a/web/classic/.prettierrc.mjs b/web/classic/.prettierrc.mjs deleted file mode 100644 index 5140bc3e923d..000000000000 --- a/web/classic/.prettierrc.mjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('@so1ve/prettier-config'); diff --git a/web/classic/i18next.config.js b/web/classic/i18next.config.js deleted file mode 100644 index bb1da7d68330..000000000000 --- a/web/classic/i18next.config.js +++ /dev/null @@ -1,84 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -/** @type {import('i18next-cli').I18nextToolkitConfig} */ -export default { - locales: ['zh-CN', 'zh-TW', 'en', 'fr', 'ru', 'ja', 'vi'], - extract: { - input: ['src/**/*.{js,jsx,ts,tsx}'], - ignore: ['src/i18n/**/*'], - output: 'src/i18n/locales/{{language}}.json', - ignoredAttributes: [ - 'accept', - 'align', - 'aria-label', - 'autoComplete', - 'className', - 'clipRule', - 'color', - 'crossOrigin', - 'data-index', - 'data-name', - 'data-testid', - 'data-type', - 'defaultActiveKey', - 'direction', - 'editorType', - 'field', - 'fill', - 'fillRule', - 'height', - 'hoverStyle', - 'htmlType', - 'id', - 'itemKey', - 'key', - 'keyPrefix', - 'layout', - 'margin', - 'maxHeight', - 'mode', - 'name', - 'overflow', - 'placement', - 'position', - 'rel', - 'role', - 'rowKey', - 'searchPosition', - 'selectedStyle', - 'shape', - 'size', - 'style', - 'theme', - 'trigger', - 'uploadTrigger', - 'validateStatus', - 'value', - 'viewBox', - 'width', - ], - sort: true, - disablePlurals: false, - removeUnusedKeys: false, - nsSeparator: false, - keySeparator: false, - mergeNamespaces: true, - }, -}; diff --git a/web/classic/index.html b/web/classic/index.html deleted file mode 100644 index b64c64b75dfe..000000000000 --- a/web/classic/index.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - New API - - - - - - -
- - diff --git a/web/classic/jsconfig.json b/web/classic/jsconfig.json deleted file mode 100644 index 170a7cb4cb56..000000000000 --- a/web/classic/jsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": "./", - "paths": { - "@/*": ["src/*"] - } - }, - "include": ["src/**/*"] -} diff --git a/web/classic/package.json b/web/classic/package.json deleted file mode 100644 index ecc6971280bf..000000000000 --- a/web/classic/package.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "name": "react-template", - "version": "0.1.0", - "private": true, - "type": "module", - "dependencies": { - "@douyinfe/semi-illustrations": "^2.69.1", - "@douyinfe/semi-icons": "^2.63.1", - "@douyinfe/semi-ui": "^2.69.1", - "@lobehub/icons": "catalog:", - "@visactor/react-vchart": "~1.8.8", - "@visactor/vchart": "~1.8.8", - "@visactor/vchart-semi-theme": "~1.8.8", - "axios": "catalog:", - "clsx": "catalog:", - "dayjs": "catalog:", - "history": "^5.3.0", - "highlight.js": "^11.11.1", - "i18next": "^23.16.8", - "i18next-browser-languagedetector": "^7.2.0", - "katex": "^0.16.22", - "lucide-react": "^0.511.0", - "marked": "^4.1.1", - "mermaid": "^11.6.0", - "qrcode.react": "catalog:", - "react": "catalog:", - "react-dom": "catalog:", - "react-dropzone": "^14.2.3", - "react-fireworks": "^1.0.4", - "react-i18next": "^13.0.0", - "react-icons": "catalog:", - "react-markdown": "catalog:", - "react-router-dom": "^6.3.0", - "react-telegram-login": "^1.1.2", - "react-toastify": "^9.0.8", - "react-turnstile": "^1.0.5", - "rehype-highlight": "^7.0.2", - "rehype-katex": "^7.0.1", - "remark-breaks": "^4.0.0", - "remark-gfm": "catalog:", - "remark-math": "^6.0.0", - "sse.js": "catalog:", - "unist-util-visit": "^5.0.0", - "use-debounce": "^10.0.4" - }, - "scripts": { - "dev": "rsbuild dev", - "build": "rsbuild build", - "lint": "prettier . --check", - "lint:fix": "prettier . --write", - "eslint": "bunx eslint \"**/*.{js,jsx}\" --cache", - "eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache", - "preview": "rsbuild preview", - "i18n:extract": "bunx i18next-cli extract", - "i18n:status": "bunx i18next-cli status", - "i18n:sync": "bunx i18next-cli sync", - "i18n:lint": "bunx i18next-cli lint" - }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest" - ] - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - }, - "devDependencies": { - "@rsbuild/core": "catalog:", - "@rsbuild/plugin-react": "catalog:", - "@so1ve/prettier-config": "^3.1.0", - "autoprefixer": "^10.4.21", - "eslint": "8.57.0", - "eslint-plugin-header": "^3.1.1", - "eslint-plugin-react-hooks": "^5.2.0", - "i18next-cli": "^1.10.3", - "postcss": "^8.5.3", - "prop-types": "^15.8.1", - "prettier": "catalog:", - "tailwindcss": "^3", - "typescript": "4.4.2" - }, - "prettier": { - "singleQuote": true, - "jsxSingleQuote": true - }, - "proxy": "http://localhost:3000" -} diff --git a/web/classic/postcss.config.js b/web/classic/postcss.config.js deleted file mode 100644 index 5731ce76eb1a..000000000000 --- a/web/classic/postcss.config.js +++ /dev/null @@ -1,25 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/web/classic/public/azure_model_name.png b/web/classic/public/azure_model_name.png deleted file mode 100644 index 36828db217203c57aa811839731d53b39ac9e633..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 256912 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 pfqL?~2!P?;pggm-BNwKBY%G$& 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 O|v0~lV|P1ri99%-tTgF3?lGD1xDm0 z_HH#=w;lK}%ekczKH}!}yuUh~?&fEY*!m@gSq90?m^szNRwHFfkxnBx8|80&S`|0<)A{yvrBDm<*uwR-T=1upAEa787$Uv$rVsg<@0j`(D&>4;yEDRz(FW-EOjDAnu6n!c>-*$ORTK z#fCV{&1rDk@ev3d?j4gM|C+bY@anT^d{*mJz{TlJc5VUiCj9L{V^|gy3Ay&JmRgL4 zLe%OYvfE!l*#LKt3Sm)4dPWXsGk`_3~rNGg+nJY(;;q$v_FfTDw8kG)BdkWkA58 z@8xF+ecXCs>Ja1AsUWPSek%X{A<+v(Clw-%22gD(fconz{U#r+&&onsdqb0Gkj=hPynZ#r{_Rh)`xaUm(a%%>aJSVUnOoABbI(%T|n21Bd zHLJKOV((%kABXoeRmVC%;&*y|lTc}T_fgYACSXaZwR3+5^$}#Tp-SiJN_jGKM09Wu z|CvCOuz^Fz4-l0ul0mp4^6FWWWTW^{`|bTn&6PZF>tXopQbm zx0C~TtC+i1bbGsH9C}?Gx3I%3{32N5m^*K!t;B2z32w7_rxoxPFs`MIj+gp)yI&rT znYC{u2T@11&ZAat?wk4B|LMNq!(6sUXxWWxp|W^hGR zwtpRL7mxQemP^is&8DH=dm=F`G z6m6#%V)8}V(DJ=U{#cn;RbTckz(yEFHe*VXkB@;arYQQDayz!sbB=W?F)JFV6lfAdEFubK{tv(z9J{9eHzgc()H$g^1@0Gt}2u9j(XB(X2+m$RgcN{JbZn zNff#=Id~tjJ|-*}sMwn#b(hpxUaegsmyk$&+SI{At}={o;7YMZC70kiHeoE`b}mSi zhYMRO!-b2~J{Ov<#7H8(5+6LyRzT!)^4PB zJPVrT>KqHV8lErhS05Xw1W+Vg5>ym7y!?w6#>S{HDIDLFry}-43|I7zpD$OKI!Zvn zHW}hHw$_~3Szi3{kGQYV^WR+-8=+bP#7gGLyJnZQ{3h%1 za*oBBXWc8KT5+Q=lKw=Fw~(lC4=94Oy%Z5ix&2KbTv)yS6K^L8XM;i#dx$ePzUnTT z%5J;r(1q$x009X+$Ct(D+ASBhS?)ajjDI`sTvFL~na+k#EX z=vc1P9*!Ei%{a=yz2K(_5I$0C;BhH}bpOy1jG|~|ws}eMK?~|;J4-AnQV^}%&DDg9 z7i#Dz9ROvBCL$f43t(kkc36O~io@?U-|?eH-ZmKr&_IHtO%Gw9;G)M!JoOH62yuP2 z&BJ7BnMa>sIb~C@mC7`hHr)tpYm&QA9W>T6MdD_>&XDHuoDgnwvlFwR2gj7<)M!-8 zyk-x6$aI8b>M*aQ0#lVyD%8Zw;I*GTPeBqXkHXJ~cY1QJ@f3Mf#*geKP4Qls6Php) zIc}!SC4!EiSeLW(fb5&GnmzuY^eDWGM314oXN$FXhqrYcp8Nhb^+fNMORn=>g4+IzI`-QPd zz4;nUo$CzCj!&oL(^!l)Q+lre4m%R{B{z#K?h9&O*E;sxK0rQYuv>uzBv{_h&0|_0 ztF{+8zOV;8n%^fwzYZ&ZG#t{JP-Q(d8EpOhqJ56*ysnU(Ka_I@#)iFc_FGbUq2P-p z3F$Tnr6NwA#(1scV1-@7!1x5q8;BWt>CYv(!B2 z=A_1w&)I4gB7_e6>Q}rmGUp$CI41F?%R$vg<}RgoG4{?()23}$|(WKO3-9?E~q83kK9bU zkE6OG^!P)x^-vq8qwGRFOPtwm{51!ux~zk&gOvL-Nc+lRoTtJlPQrW59z3Td-?Z%7 zP;1r}On=OR@Vr{ntdLDt^t|t}jRy$9W{jhqbBpsu3hVnY2tQDp5ER}Kd^?55Hac&5 zXH@gq18M>G@g3g1v$fXKU1n)6>5pR<9w^gFe53<$tELcD=iAw0LxT~2hEMay75rJIwP%d(jijvI9(eEY4@#Zv zuAUZ1o(QNe_2FsZ4uKv?+9faXCCLIK@dvnyVr~V<^A(8c;yd-O zs&PMF5S*-f8<}8%j*tF!v_WcvW+KSCq}cFjClQW*MNQV?#VeYkMaGPQp!?15gwf)t zL~A>LezNSR8hwyphP>C(=Yd)VONKFbCut_(`x~uCsS9d(pkX8HwgT~r0yP=8El>-& zJJjs=1eQ6m`yw>Ct7`MjvGr~=(fI6YhcdJIR{Yx9Yl~0GIyzr(!D?ZFr(5bE(c9ht z&tGUOFT0?4J_JQ|-bL>SVQi+%G9w;U1rllFGJoa-FU3D9mFn+tR#87MZzM;Z&S6>} z?D8=cLRkd25z({(ao!cdtvr|s`Rt1ndU>WJ!T2Tzt)J%|G>TCJD+{kk`?!$X({&xl zQkN8x=*vp0;ipx1C9zV%!ieO^vb%dyPVYAbthjmtD&U(p(G=e`KhhqDu4Jqm!8F5YUn(_^h{Sfu6*w@Cb5d8m(J@uC0XRDSg5_whYX z(PR<)E_>1>ZL|xpT=tx2A6d1WY*lh68A+0gd)Pmo50n_s>QZ)!;Hz01myw zZS^LboY!^lgnfQd=)ue_(>)xNnOjs@XW!NxCh9)dGHibtDkf3m41oCUrMa4r4k0|! zQqF?1XzeG<)qTPorvcJLBnf7@EpT8szE4-&rYp#p2vNmDWxNh$duWi@l_bc;xbFWApn2}wKVH7HF&QGPu^ zW3Wg8&be64bdV`F30?$aou~=(`3Md=9^YRLV6SN|tEuwJyK!u}zdw;htE6X}yBPG1j*m@q(NE(qBEvfn?yVSD8GG6Rzmz=FS{JH0^) z7B`-)bG`ccfeYZ)N&y$T)N1%kDInFC0y5&s4x3IfHYizRd!HR(>KHgmpA1I%Xqx}c ztvcAbK?VS~Qlwk=?1HYe>o1OK7_DR=>@F5D3`DF1fxZTis8;KsTg;&V?-BMj<2|eG zR1NnH2J-3)*y(^nO66O*{mmpibNkmp*U1~GZ{&{rm<5Qx+~}XnZ9$@s2|$Zy0bYKV ze)ZcdKru#wK#QCr#%C`ph7pSkHaF68_WH>J(7)8SErgr4H%zf6a;zRJ4CHh;> zvYpoO)H}Xq$6aXUV=V|I+dIB+1L@Vp$FT}eFmcEQutCm@o(Ly`yUEb7EULnevvxK6 zhF3abl}9$#dQ^dr5*-x0$$SB3qLnrj`nA!zk>m6YBX=s$=CkM@nZ|D@=x>0I*f{y{ zbp?U<0}I4Vi(Pmr8()r=_z?Afob&Gogy#Ccyikg-BSK4MIM#$d4(tTrfX;%B9RI?!xRR&%l&)k8DxaUxIcqKG9JSY>aJ$1g!-8sjZm zJeqpar|;gtXM>ZLFT~`LZoMgOVW($@8?B`#tr7sbT40;hdDsX7oscTNXBzGRDcSlt zO6*ZU%7v>-yV#3xQvAsy(2a;h@eR(LZWTx-KjU%$sL{Y1EF#kGAnqd;Ng%G+2~9mH zxN*zQne<*Kx_&BHnRc56mFi2^qD>QOSjef}cc?ZHf}7>yUYY{n1o8$NN+B<^g!{Y` zaPH08RUUDbbF{7-fkc$jx6`%m6fM;kjVP!+TFnG39wyY_c^o-*0dm%Zrt92-?oqja z0%CW%>0@9Jpac!4g&X+m4SAOp6vnpcz2VRH-UN!k`freJe(E46Ng_?at_9@skX`Kt ziNkJ~vPTg>j@8(+Bafsi%>)X)AdsrmUUdo5ag5FD=%3%}^*7{3Y zI_NT*p?Ir!>Ug@`2caasMn0IXkmTgCRRDSozdZ?gme(t+G^I$Wa{NOa>@(*l}0j(*`wNEOCNbn^EwXYt1gJ zbeF_4X-Ey2rqGIS{~~4gM8pB){02_R+DuQn|3VA*IN?*skuB({RuWmF>!O>3?w7$0 zKh`w!6Qvkfy8ir)Um{hj4oXaV1c?2c-Skbfs=X7E$hX(lJpX>N?MNKY z*OohC$+m?(qEo&>&Ih_nEDF+UztDcDTgTk({`5FB6wOG|50i_=pDtS#PYqvcj}-4A zG!)_=e47{JA(tgEk*{>sUv+$Q8+&)KzFpb6+2EP!t0OwvFqUePBb2|l+h}%?H5#CZ zQatJ;38HZ7au|4iud<5XMd}D&qAj}di5M!SzhVPll*I6z8eud#2(d5?E~N`IXgX8L zB#b{p3eTP@Z)|}4g)fG~?ixV=Y+U~B-tTHmIi{YCxd7a^v*(kC;f!NLO^chRX6AMd zIAcfbq#8ZCF{UWUFgH<10p&qM*(#a%T29={HZxx@2rUdx0zzwxO^0AI&21->qa6yw z=2mM2aN~R8*;+;P?|@ZHkh@$Uc$a`^bk+tN`7Ak_dRy*R2rGT6VtB{{H6^-=o7X`g z#RZg=s}G|Rt;q=wIz_h~?VPS|u)Kz8y6Pl6cW#Kz!e5Ma;ZVZvMY!?Rev(}uV;t&= z3PmHW-ou=d^FT`RvPOV*56lIxW{>YQ#KBdZK6_zGM&1kLv!pjkbW8)m&()-*VCR%B zBWg;@nugNTF^cOsqEbUQ?0%cZPL{9m@)xq9Tq1`f8bKvKhlQ;>zSrkWst0lB(AkD9 z2U*uxM;PdE! zu6LZ9S6f|3e<%|Cqy=izP~)b1iQ|gvC2}hy*05j z3Ew^fOQV^N&>h1u7pj9#_bO|zj;fpN#zw@+CEzhRASCE@9aXrHcX>R6VI+ZXMnia*+FKR+s!(XhFAV94?~Sa?)1G32x`~7riORVMK^L%#O~bz7luc99)X(vDf_Cw{#B^5&- z@w!@se7A00w_J$$a9}Ic=^|a2?u-!nmig=v`T2zO2%VfVap)9Xy<8p+3nJWDee9)0 zM{GIXyGuv2aAxl>#Tj4QduZr>SArF7J~2aHvp+78^6zL$Ks5diCkrAcAw-~VK9ud{ zOS5iDo{w$1IyrP{KPGAk-ef^OgIX1q*_OYE zeaxh0{kwrqSw1!4=i^{T3FRI-HBAb)DMt>*5s%mY-R{2F-TaB33$=3j6+Q_SI&=#9 zkG~5r(ghRpNTkT46Og=aS0~5sWkZCMXSX&O7^lT>6Gh}0Nrj#Xmx$40<({($>&d+e z{WJXgEH*mNiBPJI?D^;P+%bX|zw_d#@;D9k?`QLgIDO%&vXQ*)GGzSwRh;}|BF&V9 z{s~jCa=O%&T#+t7%~{Cu#4qc_Bj*T3zt|3mbHsA;D0;>}R7Y_46i50P%sS8gkvC>4 zUe{d_o;fbgw-2{j3pR6Hh91&~%Dq_VsjQ$Zd&$$sU+J;MpeMV+(0pLdi4alfNOd(% zqwcS*SVrY1!7)3jz&f^8qKaI=G5T`9JUGy8jNda0dyoWTJ zviI=CdOj&<3C*LnpdrAEhTmVcwim-&;R@5N^5 z3$~&JbMA|Ea&uZYxt9{ZCQKNzTq-EoaN@m2jXJXVS7qPqzhg%nL^U#+OY4)xNhPeb zrM_jDtqtX6`L(q7l1B(%R(}4xAeF959VW|S@|+!C0yaWA$T>7Ai^Y>o6BggCB5jL*|FB_1 z+COMDKcuNL%^)r~I%74JvCkkZoW3%c=6JNq^$$ZSt6k~PX)uw$-yGS_i}5gC|4`K! zBZc>1xevv^*Hf?wgO6y(h=~;jbGxW&o4#g897 z?|r#0LncdaGo^KTK)GfW!D>DlrG6@MPbw$4OG5vpbJ2wEbnd&zC`ZbLpzA9 zvzNj>)MGY>lR7e#?i=X)7S=U&b1!Il#wm5cDQPh|29=5>F+vhYEa{HI!;IkgPOsC- z@~1^C*UcNmE|K3uVVX(YbXOH+0z2&~@d~EDY7QG*)!bOV=ae)$7@EF3Nc8k~AGu9@o5TL+(ZCXDZsvjn zTrP{yoIdo+?^&KbCVRykj;e+#K(cxl^|7CHNbwP)hbmTG+)3~C{q^I5O)^gesb63oZc(0CYx zi4;`5qa=_z|807ZLu!(1A5ulA49!y7$4rsOeb=~3yRYzL)NZHY_)5@Go2TPYmc)E0 zWATBWhMMzw4yAQpSwWiFHEoQueGMufp+0|XyGFp{Hr;i~7w z*_)tHU6iPmJR(M6L5#^!mVF5?ArFzgK2rGm7F!2TzssfPMT3AEGtY31v$wxinvLX= z>I;dG3L+e)`v@UM#cx<&cB~EgR!-8;39Xj(9N`e9LD>0r-A?L3s}Ve=Vj)j_FpRj9 zSzQ4>>Yk=G7J9VNo2$z9nH9!F=!ANOcT3 zoVfj$!@W}8hQ5kZm@uDaZ?)GLSaQ$25`c~>m?YnVA>OZo6v4~{&KG=)1d5v>|5iO#2mO zV|C9dx{Zc~)kUMhTjt@qv3Wuy(@QyF%~Vsvpc;oF*}O8Svto#{FG;bA6TbQS9PhEl zy%;CPR}ca<#~~(#Kk?9EwWlHr0-|7+giU-Y8-3K-+v5IEboENz z_0ZT?5tnS0GD|!{VQ2U%`JY3Ogf7d?O2*ih;+d=~Pr{I_STB7q#Vd)OYmJGfWAH^o zDVeXh0+>q#juPy_b7WzdrU3@-zrSaU(uF~?m!PO#-n4^)hn=-0nBpExhQAiaQ`H%y zjE`8wko-lcPgv;o<riJ_q-krs}5{M(JbV&-C5 zkW+U-(Sv=;-WFQiv*|OsQ>@T7YB`Y-T!4dl5v@=6Bj?q`l7>)%PCP}km2I@yS^6UP z+jx8!{i!2}!m4`HxGrN86(iM2?dx^^zu0@LxG3B3YgBzvK~xw(I%I$Wq@)`JhAu&* zL%Kykx)c#+=%E|Mp}QND1_@~xO1hh&!TpS{zW?vrdmrq>eefOnbB1}IJFa_O>t5@+ z3Y&UNr=xUGKTsaf5mradMYRnMW8{EAtM=2PBin65L27lr zBt}EH!9As;4jYU%j;#G6ULA!oZJgkTqk|K`=IP>esss~{uXG9w(j=&-fZ8lm9A#Lt z<)kQhk2-rz4B8UiB6vrTE+KWv2UO&LP}Fx229(nFD+Rbvc0*OPk<(ZxXf5=8wsGKT+=Ql=Rnl)9lONmictDn5PXef5yMIXi-g( z9~koO0S6bDVNW^&86uzHt6Qou1^EKe8P=jK{@uI=Q@qGlorW~;ypP@8UkFSq+2eBr z3B8!%X5qH*96HM2v!(c$fyQH0zA=>m;tkfvrZ*gt8h*^l2U!;N z=lQOuJYt`eh6LGaIJolK$i6}KLGtSI1_PqA-|}|1*}nZC2c!xsNRB)t?LsLg{$qzCpr(VI41Ft*Wl-<* zUusHb=*Paj?iLU*A~){F7X4P;*uYng2=RO=oaxDsQr;&b;PX4SZ)~LevxkVfNtcPc zk*}Ndv>Yv{p0IH1uNzAPAa63d0|Cu+UgBzs~bs6B4mrC zi?nG}FQUXd9+Yy2D-COc#|ju_?M9PKQR#9ThMTtQAND=XePgB?!^j8*M6YVHtfYn_ z3JP2fY?O4}LL>9Ks&y86Paiu~l*0tZox_>9u4LuAh?H zz9X{?_!+Zc2jqXRDRiuaxtjP6wZmONmP_TUy~5w>t<#+Utasxlf8r01;GhMhn?9{hLe z3QXV0z~mi%4sX5<8%z2{Q@Gl+XIS!AF-03l1bT9}y zUy3uFuYd-fPoz2GY1~HbxS~T4%UBqtL};G*Kt7W|5$h9IjM4}@GO2Wfdz>-eyU4dk z;vIqqJf`35>bTXO$j7}?k$=2bfZvTukb611k*+(SM3%BUH>wqmz9e<1)NbAe-R*TcMGt{XRo?Oyblh0U-$wj>s7nCvRR#_P0+@M?NX*t`Geyea1 z;2HdL@TBOo-8F#w*aHHsga@HTwqO2f$>K}S@kKi@=M1@CpugTT$tGUoaD;r+rm4xY zT~7|?q4;HmmrfCX9*?C7X^|O8I|%kv_v6Z=k6ja9eAZYoe_D6VKCf+WxZaMXnOU^K zeKNwHL5(x517H+dwYA9~ZuIn}+NKp|UrKdahHE!eUhAk1xOJ>+r+{45wWK+T(?rJ$ zS0@h@?~k28nRHa*Z04BT6Wz#}w|z_t$SlpA{qzTbRO?ov_Q>%Vac@!5pIHi%Z<~Y` z1PIVED6|kY@{s!RUpK|$EFNw+N8)B6?h~ER6G4;SVVB9(?_z({g*AbrkqGO%onPl5 z7f@L^H^^|PN#Rv3#Ms#Xb&Jt*e!63=^i?ELTL4Wuh@_IvluFh6v|$J*o7i#*Z{3ut zkRbeVGBQp6Gv$fYXK!#$7w@1fcnn5)tH*6aT13}0cxiA975Rg61v@Z`4y6$gwFwe1 zReR|lY?aHz1cnvh_nFUdfy^>4ZSJ3K&yAgxnXZ@&%9OVyrt-%QkiR*P`#J1(r3zQG z4l;{;fSqEgMCyi8O&{$~-*}Y*s#Xu_X4>Yw9*jTNqhM;@)Doun2a~Q-NHNsFgw`vu z23Q?egcqLws`~}BY#AzgvCumKfgg$HuFQthN*Y8{)&s5TlW!23>)cWg<{(=km+t;{ zKA6RD_DN;z=J3jkY#UD%=)0(Z9@PwVZeh7LiKFX5H)HYSw-LcdH%b45GKvqda=12* zqs6!^p+(u(B_m`w4sZ87zoUeAVsG--@zsoPl)EAV11AjR-n$}maEmhdSM3{U*qx|* zaw|1rg0x)SNk)rSppb3F`hposme{uq9a_=~a8EFGjFg9hC*lz}E=C!egq=lTd3enV z>Bpu8DQ7HpB`il~$m)kOMA|Aoq7UuL4`#Y04{lKr&W-Q|y+@{Pd{Wj|B!d#bC-V7R z3=j%K)Y9Zp4v%J#ls@UCe&ORJ->wUm9~uEd(@K={AU|Vv+LX0F)ZXMd(pdWaC?MynaJ|W} zqpw$&d8~FU87)qFuvSlLTj#!j3n#_(KZ=hk$TAQ0+oaG zDUTfuxFmJsV5QgBAJ>@1!4H(yW)#iTabQ9!NsQgE{EI0AKsXx0JT3G(NKmeH2ZFxR zH`)^<4Ozh4{SuN^=4X}V$Ma?@ZLyd~=s$i3?T#tYRZ2m4w<&GE!Eq_(ZGqlC3$+7z zEL~&affj;(ym81a%qTcFAjiqp-BlzTNv9`VU*DgRQ#mw)p>yiqm$| z7>QwGst<^YMMZpizVgHfPHYO*E%b^K7jvWPgh%C-p|HQz>>G?K^Th3so%~8LOvz0r z39T?3420%5t*N>kRCAFE2j~{+K$29<$l#JS9^o)8+jHwV;HK=8G=w>#?u)v<^b!Kz zW>?6TWhAFNUY?xAJ7m}UlPp#tuMZt2$L}M;ve1k2!%F7kY4R=#UDQxUzrowRvHU~T zEMDw6HTuB8rJ6WRPH@qLwZBVcAluQQ!Is9f)jgL;<%Gl0!k%wY$7@`rsDn?Znzo%x zf?c5><%DXVQ^avuBiWV+2>GPX+BodICi<#3?+f3fQUwtXh_%$enW8bnACP8>{03sm zNKjhq#x0XPr#&+(Q?^QcmzP7mfhEkR%xXW4sFMZblQS#@PI3;x1V(>53G- zk`?l^+y<=Brd$niaDH2q>!>Ca<_uMhm3vDi891Wmr{4EEEFkkZ&LW?UQn5NLU z7(oR)(WP_<=Mo5J{4+C!H!rn<(DrHU2yIWS8eHjl6~=M3>g>W}*j2Bau5yLL)+uDv zNNGgDgpWYwjhVcdYbUKnoY#hzx!(!BxIUFkj@IgkG@u8v!fC7W;BdO!F6s3Olsk}- z@MjFmRYgVgzu;KU>=*|6IZt;uR=NS`*tD0T+?AYdQc=V+E6l?u%K^r1TSO*h7Ol)6 zvP&Q9IuW_hd)5IftLGE67GZt#UXIJ_b&T4Q@o06YR~zKf#wQM#*XAy|^W*1GNiokr7vb)mbkURo}wqnuayM1%ZW>C=Rx5KYcWVjPXlz{YEIu>;7!)vdzT$gkO- zl#T@2kAJJuHEvVLjndJ4NLdCS5?+X|+X7pY^j)K*Ir_0l5mNoMCnFIlv;k0l=3*;m zgc$U8?PoD?C-Mr;bRb&G6aIEn%~eg9{=r$s>x=_vtCiNR$OK0`mqA~YcAKtWp(Hf< zt+Ci7HP(zZeWy3K1gzK&;95T8?8`O9i17K)Nrnw&uJTh+W{c z!GZ|fSo0Du+NMm8b}EO$`MA>nIBq&*Ej@hZ2B$M8Dr6iA`Eg)?W+`1b)a6k1g1w{n zKME>i`EIgZA|_sBeM#UlMBwF^b&bAHrgg2X4o@nel2=Tzri+SwTL^>tJZs8Lw=|x1 z-K6V>k{_!p{j#kF*!O1;uj1OY^N=ArUJ|{-*VGNNBHuHvLrHuJM9*P;f715}(%(od znA_SsLrY1>7UUik5cAkesq9KQFBY5DTJHYbc)x%DVJ*CAK)FZ0`r7v+41dr%_Fz>u zhKM=iK}ToO8wx9Mb_{}ub`V-{)EU0sp6F85g?&p3EAB#1Se~2=B-7AKVA*3sRj@&$ z#sMkG)P7 z-O&N(Wz2XN(ZHz*Lc4xImT);&>KOQ__*Ka0O+&TJHK@7DhfMQ#e4}N>7;P-1!BG zKw`OJ%t`<^GZ&=+3y)5nyDe7fu`Tp+FQ#VM6|GZa5ENarN%Ju~T@UoVEjZ&0w5|rfud8peug!y&h zo73uI$7HtMv}WupRDW3rXHbvmB?Wi{`ML}_Az_kZe9ktdSlKIR`aLl~QHsdNWGkJg3_)@_J}(fu z!0nd3`4yMHKE*h>B@1)^D?50>(d!>#=PDjy@An_pDt=}?KizA^a~P<0yGd5$hdq&* zmic$g<^LUXviB02a44-Kw4?U}Zx&Jy!GVmy5t@9(#MN@={rF0H&A7?^OWUA?S}C1fvIBl$AoOS(d! z+@sCRi9meIkJrLmC9S$s!Ef?;`z}ul8w=QERTB3Gg4=F^=zJ={J8~-K03j_0UXVo^ z_&socKB_82@5p7VrEI_Ptx7mIXbKIrdFcldK@H3Sw2BfLrR=$e8Th@a!P8Akgi+-V zX|BbVHl<-j&1#~^m)uLC-BoT`1ZNUI+GYz8- zPAJjmMRJBU{h&{^Ku2F)EX2hT;WiN1?x7ijZA1mOU)n$GjU;k)uNc$!V-LLx5_*)l zDS{a?*@nyfrNQjxz+5Cm!XJd=SbP zu9X^!6g~IveT(O>f-=TYFVlFEDMtsAQTLRmKoKE|A46iDBFM#~k|jY1y_F?G%q=sJr|)Lw-y| zP9aQrjx1=$XyC2rct_Vc>(6G=oxu{m}^#!)^wqbP}OcxGa%Mdih{H_gERAU9R$Zh#ERuWgFeUYBPY`6=<|`S zk8Ihx?W%{4qTAcFRa22!yL-l|PnRFrYYu*_%-=F$9^6!vE_)Jx{P-VvN0ZnRI?6DT z<-HwRJ9#Dh+$Ntkt3P9Jr%)i|cw8(gn^MS#R45d_gLsMz?5;duhag{Z`dPosQ0&5w zUcM&9=MK0hVNv+Rzt6!o4Z-zYzTCf|^g4QtV$iVdC2;>BXx4}XO!;3jyFfB`-2JBO zB%k3w#d?3vMDd=7<-G7s0}}^8trFa{N;wVxG$=OKL%@6V^{!Y+ci}bpk0NlfzSIni zl^{dB{|YL#Zb<Ra*@ z_>!p5HJy8%u!VBXWXBSXi||1i@X|z+BApng8XUF~b>WISMaF+Wb+WU_SW!#uD8B=R3ehH2HyRGSL6> zrII>0z*)n}{~VjZcSbRr0h;m$egj`Ecnut-;04w{iU3J$V;77d17!J!4FbNJpA1AH zn84p?k1we`1E5D}o6etE1~f4n5a4~kMg1T<=*^+ zX#|zwo{M6XMP#%JIYxjr4>VT3eDn3@-TPC3Ev=-TSdXi=_FVO{2xlVir06yw}=G#{4_Zle_b*mdr4J@oe*-ZHTx(Xs` zT$64E>T8O6?>MLHv9LE^9?W|Tc+Hjo3Q)Y0noiL`;o1Fg+#SOI7UWaB-|MmJm^2il ztO@}W>KrEycw4+D)eBF4U6-4R>6>0f)DM)V^8DuW-`DV0Xn(J!vG9wzU)QbsoiySN zn+Cf!2l>zmKw`K*zYxMftpW%Z=i#c}3mARhgNyBIqeW1Jd)r=2wn%27+woX+QzcHh z(C%>h_0gNrms$@1mFsZOcJ-X^x2ym8p*XtVKeS>AKthUe;hh>{2F-=dy3HaAc0gDh z=XzEWBx*O|*h!_gvApfzcQMabU8~$$d(yBf8iY~A2>|`pgGie8J}Q4g*t<~KexdR? z8nlmof8F#yqp66wnFCpVKP+yt3pu3$gmw1zTp5apyl^xZvo`froc_JV&ud0bFenb*oAE&Wo9 zY^6aHWMV33cZp#nTafcHFx`-W*A4p}yyHH@$+IKseD=?N#MAx#6A8DzQRvx~w!f*a zpD8p~3u}DucX8S8)^ynW7%zDShi`pgGuQTL6<1SdYgX(q)zo69JbcROI~z&wm(99G z+u5rfH-~hz`2-~f`@zbWIZq1=jz8o1SoL{Y0K)L=H_aC_#hGTu>UM_u7dP?TzwT94 zH`><#eCbr>++o_u{Q-}DXWhA@<^NVU9}j&@Z;ok%sB6tu*>~MHo#Rc%mGdjf4%AIR z+F$M0yv*icZR-~Oa?!avSM+$o)O|GHaFnY&rJOX5sZ7w&@0We`OqIF24skc{G@;RH z3yJPnJnuKQR30%PgF7Yz6yYiJEJkMvi+v8hhi|%QgD$5ySGM}E+8D(+z0vtb_WC~P z(bnO#d{P`}VrQ3-%jtM`LU3&tMdq^BBSU-hkhA`94NKeF}&{ zXXE5OuMgAb0`R+68!(zj}Tr@JgpT4delX_3L&?G0v3IGpx@;pO)DT2-Ja(y z_E4_3G_Z93vwAwd3LkXQ{j%qN2bdVz?Mr`6%^p|bLMKp8^;0viCpcd(GHNc>K449&~@8x%@f*h30B)Z))z^^%PJh&v9iy{U%3LD#$#fO@LL> zOfh>gds3ymi+nB=FHo*F`%=q*_Y(M^13}0qf=QSCc9LHd*(pvvn6Ot+TU@(Wo_@6j z=}S2Y62JT`oro~#{{{S&DP$^8KZ-xZ163?#XyL_J3c>&ce>Q*sD_Ttbn&!lbmZ4*hltQ2J zaWYH{FOW|qs&$z#4yru-b-5~Dox|N?4=A3E-lV4iLe)1IFGMmaT{AK$SZoSdqo~U{ z@}C$7Npc0RiN@#-6dn+0r;{wLc<1?BxV>uLQAOW5MMDapz%sWPSO~0ADn5||jGfKX3 zs+UBM!{8GYK%j5(aMLRzxPZ2(_p`Js{0Lq<4o&L(zxG2bSuSxU+Z2J zN&26wd2Ha_LjCp^hE=Hy-weT()3z%XAL6dGu)wL9Yu{rIFbk{{mu{q8QUsyp${O+N z36&${#rJbdhJNqn!+v3o8kLClt4tT&@hyz{TXBkhXQM8qogrR-C}SF0j$`Amm>J5iUo9fqchPflfbJRZpwbNOI*@5h%S z;D9_Fn_kUIeofBa@G}h8u;lO2z(|Lra$1b<2;Vk*InG2+4Xe`xh@ks#hhwcR=aad| zY^}AKKY2A=>FIb3_@e6UAHU+hvHW<_@T6|Mvaeb%rXVF;+>tB+I=MnxAGHT-Fptk{scI2+=q$gMFSp58?1Zw>uY z8)wB&fhF?uCZ6`=e&4+hH1~nsaiXh{GNPKiJ9$;!JiDUw?mzU-f2i#G6U$0K)kSmKrOn3r6-&Lth_VLe66HhQ!+dX_G zX1C)RUq5Z$w?D>0SH{}ow}g;=C`;v{(YoRWG=N;*D|GP5?_1mtbMQ8GpZsn(HY857 z50mX>=r6Fgc(9k*mS`mavg9M(b0FwTIZrSRx2C{38lTq3q$|KOHBPp#B1=1n$>y2b zrbQSvs!x2A@^yuCPc3>r^8(&Na(sMXMGXVA55YxOI|Ke>q*tKvu!X@2W9I-yzuHR&TAATu zO`uF(+^B!+N!GCIvuk@&LeUM(=#=Zd_3!@yX*;=&q0osaO?k2IuLQLW8@3p@`({>T ztMO#ke$dxniGJd)`02ObaJU3G7^}~CtsxsqLJ(Su`2PJfj4>oc;{~;C0$Wu+WZxA4 zLiZg!hm%oJiqI(_-&mX<7JqCru}f zLBN^eSp(p7J+Ug~o3nrsr5d{$U_qTE1plt%fCq5F?fD>@*_AK!(Y$@EBYr?QXA^KG zz9oiq7n{dXB|V<=`vpN2Fj{;Ocb*z=Sm9L;mQTF|0HT;a;Ot}*X!qa);V(lLLYKg! z1@aa}c9Y0q72!hrG1;0K0L|OC`a6*T<4yGZCc8*bh4N}$@$Tp@h9a6Y^`YV;`OOX3 zd5F04F=39HacE$7Go-d6)qt;&)KicTwF{B?M87rv-`rL3hn4^)?fZcDd zsTkdXbc`}~ZF}ng0?I*b>P?aV#01x=*GbScEACAsjui;LGTq*$Gpa}?-G^`mQb^lq zXMfGK^O5W-a|B;!->rWi-q)`CdmQdr0lI4oHUF^$kk0XZv*;_En%Qx+AN&<%4$y|S z?h;$|yG?9LBOi>$_ivTjt^u_$%YXit#I+D5q+bvGx>VKsl>RCSxB?@6zv8{H<&Vp5 zb&xMU*DUP~htvaB${-9#Dcl!r`i037AT!upokBrYbVHY}MXw0!J;OhaJ1yJ?&)PkE zhN>+>kFeJ+MO7JpYpDNF1o`uKlb`&X zyt~>}j%v0_+@<1a004!Rnh8S_@^$dzay{%R_UWV8VsCS)n54*2pvqRm_G9&yP~K{? zck!VHJZM?J=D)m?@spZ&q9@bkg=%BjX6zjTFQa=2q#<3%<~!rB=x!Vu5GX!X@bl9q zQ6@?-OX4zgLrMHv>Wk#YzEx_ z5(U&DjGpcvgYO;EH!FPV!+j5#LoEats!OU|T>K3H*tETWXblRx>K`h1^p6sy@z{9W zXI#cBd4F_RCtMS!LQ(*EGGM)K-^1w6GF{4+l9QJo{$0#1Jp})C21JUQ@y4Cep0Wwf@|ug8U9qjoal_VD>f;jQ>KRvx9e|o+7s%>-<$8&br4|$c z(oKgC4dND3&OyS{#x($B=N4H2MObb7V*IhL>k-K$F75!v6%kpNS0x9`7>osOGyrYs zM&&x~*m+z+72 z-CdDSGn<%%5Zinjn!7VWz=>Ce*7Ms*@N0Ak zte@F_U8v&Lut*hTUp1K(tChY5pdovax^T`cF&78Zt9SoABs~U9=B@+Rb$|DX=3Pt@ zR@#;*fB6Ccjq?~z0^?PG$l?ACHxL6pzBy75I4vV0yv?GCpb{K<6?e}v_3unIfZvZi z_A=ZL*18Xnds9Jx1IM@q z!PFiYBe`-s%xLTv+ANp{7>DUSAf54aUz4J#GaRo!Y1|bR*|t`t0EoVb`xyKX_$6G( z<{Rh6&zca9rWz`@o!ZqZCssg)yQ}Z>r4UD)gJ>vM+%?p4v1T!xLJXKPJPc096gXf7 z#*lO6cb*OyT2J@h<9WYw2ksn)mq_N(d0LG&K%rS@R6^t2)lPNJ+F|W3K*`8fb7tBf zPdPNtO92q}4PYL|!(^kn7h3>6YUS*t91f=yn7N?E^XS^^;BP9eoX2Dtx5xiRyb>Wy z)))pj?}i(Im_^|WWR3hvJJO%fo%V9Sfrq&7N&OtJ8tstnrRe@%xLbqiDL_I^1$=;I zU6YsNe_NcuoY}t%Wk6b?`$wJVNd8^mJ3$yqN;wH8un{r&16D-uVuit+YBys(q5Ydj{cvr_|sblu?;$cl$h!!>IWAW`XSpgz8GE6_Nw zb>7~=OK)>(8L-#60!!~pSbee2wyp9J$mIK|k;Yhv(d7a}0Atgy7CP=MU`Z23W9TVi z4E0BaNeKCI6z%e+0Kz!i)hOLI`Pv)SBfMQP9EcgpR$1}$!Fd>6IO#)?y{{Nn^7Z)d z#74|(nZ-!(azm?`TG^=)i^=`@gTzN$&KsUcUFgfNVhIa)G4T z3cQ(@UqsGczg1Sb;&;93r!`f+m3+J^Z4dV^HYGF{@!6EMO=7SuC*yM zmyH1fJ;$tv8THtWnUM-P)K_QP6poNQMS4O|4H1+C?gFGMRbr#QSDdCWN*9KAHlpmC zE%F@n4gmkp!6RrMDgw;^>vX+;2DD#lb_Q?I>34tTZ$9f|^DL3=DLiUzO7CX#*GR9u zBM(9K2sw}W5N^8w=5jntt(V2rU2jWanK^P2qw}HoF>fs<7Mz!A(aPpHeX(ft!| z429n|!QZqW1{vHCHunH1H6eZHK3?U5+TDDks}0xwqWYb4huq|ioZT?{ZH~O6w#4Ia z05^x82WHyHmT*z?tb&fylCn1{W;56=L_>S=QHG#T7L6)UEk$#{S0Bh7imlZLPWn^P z)7D0&EoT8|r{2HfoStkaH@uWx%x_V=1=eAQ5<`U6>LOykwlix{LfBeC= zjz_nm;gDE#Q3Ky5T@lZN4Ce{@#Ed{`NBgy1=%#^xbHnx=t;eA}Sz3n+AH`tBafeTz1_C z<98sWp})&HNOPA^W>X1%O-ogvW1&7xqm{6Qz{_wQ37S6L0@kbpJ{e?dh+O#+cijnk z#}Q%@@{Yo9*WE|sglm$1c{*D*nCT|iAnGya&7JSTl_1N2yJs2R0bhu;tP3_nd>;oC z@XBcFwthF$7mtFP5m(^kE*)aVwJ<_?8cs79+@ryqN^hwu{k~7Pcdij9ZoqqmTX_hi zxb!d=;xp*hb_*K;)LpOWW;2yc9(8-H2+f1PD!7gkTNhc@3~a|g%V9^R)X7y+`W0Uf z1;M*_y;p_pmHGYsDbZZ?@m$zxj4sxkw?J z%1BQK)`y9OtwsO=&39*m9AiGD=okkoeoSIfOuk$`=2t6JPWc4@{LJoh-@?}Rr()PI zmyZ^8h}QG$p$RsYyS(ZV0viK!6WZSg+z0QP74IY2Lj87c4Wu-8`~EUD21tru)rZ|b zEkzc|>?zOEvC3z0W~HEYrQC+X&x3iaS3LImi%;S>jkr)Xu1fZXpnn0AwlIb?0T?2t zTZ9_X#)HBTZTg!!BQ*7&->44KL$;T&BwqPjNX}%}KLTrXafhG@8Dc#rWs4JtlI|0T z6>%$elmfG^Yx<4xiImNg&m(l`=Yp_*7DAbh zN+y23y6^D@Fr@M)N>qS2$3}*zA)~Iz1sAab+!7E1X)E-J0ZBzPPK_%c1XH@gl?MDV z<}u=4v}4rAwdZd3avIjVNi`7>K;664O)^_>#kp^whBU%r31J)j70fCxdIG-jtZFSg z_ziv5hHxY^@qYZZHi5b)J6zpd=e}(M=vl`NiQL{RPEoA8AenwLnhwPAn_>b|6k_^~ z^2raCrl7;o^~=Kn2ZGiU`oIdUtEU|aVBhW@9nh=tr~M-1^l6n-P^(F*5pMc>kpeHM z)!y<#LZt@YEuyQ1BFB7|mb6pInwMUtI(RF*d?`C{lT*g(W!t=V3OxMi;gDO$FDD9U zq(hqg_WX3Iz+t0Xu4TXIXlrS*?orGE{tIM=8-?SF=uba-Sn#MXs?DALW+P(c*5ps(Le3ewC z<~}Th5(^6S@i!I3!f1EpIIQ0?aM(CGL~V3BL=WztwEgq>#R<{A1dqJ^7AI%yF?~;0 zKw1jerAvNL_9MJVo<&W)1sRYamspzf+|vB9Ir3Uwcx$(wJ?BFcryz9*H*AaGwuao5 zx)|5TUM^<-c}OHSsS7ntgo49JAl6rth2E>mTuwRv;10Th6vdw!OB919y-~bB9MDus zr(ur}@TExG`tUXKC==F6&spGtv%-@p-w_H!cOFHGqS_S^1%#7X17R;B8;bx#fC&8~ zHTAh@VPuSfJeuv>igvTtNGucmJG8-EVEJAz2)v`HLG}XW3JBy$d_<{9z$pds+0B)A zIfr*&2@zW&)8+bw=E}2+rQZ=e>!>#E2Cv2m9H#C=%WQDfy>fH7%^9z;Ap;?nFQ>FG zirUCZAqT2+R(`vb1K&*sQz-t~eN~?myh50oGm8qt|L7W3lIuW(Xb2_OyAncUDM%zG7%&#glQ?||h#!EnogB@WFvpAbFw8_o z=2Y>%XM8;Ee4k8GB$B~bYb36V#pFv7;EkFrkQ&K;ErBFA&e=i6NpD|NBD^8$p>j^j z(c1*~OaZMv%xW7OEqQG7N_ z1m|=&x&CMxqS_ROW$-k?KjaD1%2ts&^fUg0jr0xUUS@RDpK_L{KzHJk?|i|lh-l8d zMc&z?XoWAMZfKwMe7&e6R*bkkYZ{q*>?XEuyG4#mpL`7d>|In8&X^=^_=NwAQ}rGd zXhCqlAtwUXi+XCz+CMVxPL-}`7JN!~OP+WDH&Iy3g0){?w(juM>z2k7u>2P3z>7jE z0brTbgE%JMfnialnr#Z1cq zt7?H;QqwT|Y~NxfSz-RF;JDi?TUz<@Y~~x~#qaf9m7;iyURvi(RuTiOx!K-VE`QQk zMNFEDF&Jx0hmQn=VsqA#yk~IgN`hjAbtlN@%Gb+lNOy&TaLk7Ea`owz{KN^hZ6kD; zrqQsQgci(6w$Wnd2apy?9T4TW@M8G2_Hz((o7T3>ItpA2g&^=gViyMI3?1)CSiyNpj~@-b7zdn- z@G^MC8r=!W%ZvvpvL*s?U29*k3m;?_!=5p_4Aneg0E&22#7bXut`ew2V~-hqr-Sw$ zGgkQc6$=IJajJ~Oy64>$)XohOTIv}cR`s6yhR-eHRwn>kw35aw35l;WGn=?F#{;3Eqs%+?u^wX$}Gzxn~ zC58AyN~_f<#f%U+>fHRam&?5D*xHK~4s)3$r{epci_x*iIZO>e;sWEhPV3nq9q2#3 z0RDBvuV&htQ~y>uJ)%8vf0r87mB14RO*$SQ3Q`X?7`VVO1FW~D0xbHS2`^kv4pLYj z&D7<74hzLyoOVdI8*$4R2sPba^+?r{v-KOeFK?^d6MTcxRNuXM15d5HJRIkL-MoO` z&r?AxQV1ds*$aq~dm8nj21F%853;GGV90%ZEAvh0t|gpXGW0o^e@u^MxQVFJTxu|v7x8Kuw$bW3ZJ6EyPx_LIP4P1`^E?Xb-GFm%=>Oi+!p zBl8+u>KXc3_s4X?b~B^N5l88>`%i+LCawJ6Ifd6nhZ|Zj40J5?YBPAs)#%25KN%xo zA%nE*7{$D!>kVbN+I8lVOSe`uA9GW`-_4rQ%?sT@j04s>N&*eDk2#Vid3+KH3|Vn4 z+E3$V^a2>yHRk&0`a|z7N*l3!$Z6R>!BDIDX@GQtN{Z;*8!)boE6i(Z4(LcnhYwzN zL^C~KMkH;M`?TrMo~w$lsrpD$2K#Ro*E5@lFpC>oq{z(`Ref+%kakjV93{EpwU8(f zp7ltJ&>8gUsBz34ILdc3{aYZz*cyYG7~06D70HRM!NILq3L5t$==0v+Abemp1hq)` zk@Q$mJi6tjnhBC{(70?agu4^&Ug}EtO+s$e(>OMxrV6GD{@Hb-tldS4sk=~2xw~08 zPdN#Pg0PQ(-fZeTn0wt_{ZXvcMI;sdd)L?u*?ZP6{MC-|OINRbwJr)H1cZTNTS+&Z zX_j*y5F-x|_+vg?XH=@5Gg?&GwBw)|zBp#U#rGhK26r618H&Rd3mtC83genQeV;=i z3+gAZL`fluCQ zX6?Tu%|8$L`oke0ffFoLGM1{Io|o^r?zP@Bw(WFZ4YKfj4k1Zg7*C+!AL-VU+rRVj z7UC4`?_#uAKlWHp@4ZBhXJD9%lUoB$rwCAw#8^ablqU{AHgf}X{R(@e+|Z2U<1+v_ zb%yV!kTGr;F{hd$$rP7Is5{ACt_TB7|lOCA^@JUlCli|I^cAyPO(F2Mn8GQ*O zn%A9Jjw@s*N+TzuMYE!);#r4}Wf60{d(|g(``eAQEzX)fZg6{3^*O@uE6zXAC=+w} ziGfBh!C#>B316dVARS?aN#JOXxY03N^C7qPo&5@|)ujVoKG6jKEImNUNwXwOop4y`}sMoXR4cJ;(Zhk4C z53FqJ*rW&pOnJGY*maU-8CEn`$l0D(p^;n=pr}F$hiKWkX`BGJ>vV`2Ph7?Ox=0F! zej5WWkSe%h<&hP!9tnd?*;;eya|VLx=%@HDUn06ln0aEZ^bW)=6V=ga)f;$0>w}MN>d6gi3p?LDOkvyEx(o)YC$N`71vjqZ@Ml zjA#Zws0)fZWE?d(10TDVl78+;Z$Wnj*oH2j_(&4!;)PQIMz zV0s$-e0Jg~_2_qj<6fJ82C~m>CAT;lyC{b*|_K{xbWVhRGXBN1>`)M@q{`fy+7R>DZg4f5=_*5X`}lRm4I zXfRUAs+*h8ax<>5Sm2-9oW_Dj$OkaC+?N{??ABTt&tgKHr5%`}&o_h6Aj=I}aHuw| zTLB2N0DpQ=dyt?+h`%vk*Yl)uc)zD$yCbAGqMdAT*$}dQ`!f;`rV`b2`uNpl6>-$A zw44q}KG@+Xx{GI!CF8pV9M`l1I#*dq<+$P;?Hl#oazkEb{g)2DF)9d&$f%oGI760p zJ=Njr@BFn(lZ~6hdOU(({xw|1JgSBLfn)nfQHjbggk!e zaagmMt}UlymLck!vqDMK`;ISs)z|5!o{komHvH%lvBzQhUpf$$Qy2cPM#Os-1$9+Y ztoHkBt1A?G=aZ z^~q5gRA3iFXDGi5I1U};sH70luFFYm8IBfzVj-RN=jw)-5xA%Vic2A6A(GH|Y;d$k zv}d#>A#Zdkbti}bvIm(*3w|@Z1+nQpfn*ig`YXkS(a~F|3D1GRkT!^In~WdjH+tOw zEN%%t3C6VZMWqHfo;hKo2{_jf8USNJmvkiJm}jyGripNa_~rR<7_pW0r980^%}*|Sx*qsWKvWy{iUBXwsv(nJT>XPn#_(t!H7^}+)E z!idn(R3TMg%1e=KYZrJO$QBCaf~&4X16WNDS<@W+*%y?qx(LvaC}?9&yelVPOlI>g zkR+L!&Pq;n#J=S+c7T|jI=L5o)gi^ zsDL%?Pmlxa=Y{z8-E9Me3u-zcp>n@dJEbb~7_C3|noq@%G~PjDx+&sA^{KSSSDw z6Hjpp{(I^86r-C5jQuiqzr8 zu9{(Pv0Ww*s7*L2c?W?@FUirb#DqgdC`Qm4fC|v`LXXqw=*v!yD0^lT{m{+sTSell zw%uJEe^>$7p#ttz7ms5vZ6oK$U|@4s;?~DEQ4oO$ z0XouAibfc%@XIJi$nJHa82H?qeIp=74UceBtnlF#xuv50!>7cS=Uo${ z@hHS;BmqP+ckBS%Khiu`a09?LUisp3o}(*>Gl6fTemUEbw`xW_oCYMvEQYA3bXAtj0qh2wunixxw4Y!^S-XlAy=`0 zGv4BucJZ33IZ?N4neCi{Gc`~gX!d4@sKo7Y4XwqO^`N#z>_)>Bc?x2NoCs<6C)Gp- zyOIMWr4~wcS&lMGh*!Xqn05;}_bh4ehF>Ryg;k-8c<#i;?v4LS8*X>aH@I&qms(lK znL{P7^nbDU)=^Qv?YpQV>VOCg2+|!2ND2U_xYW**V=!HI>UVC$@{*p`@SxdQ7_RH&%rP0>Mm-_O&#_$*Df>QY z$Z%qsiD6uvz4T#JyKo@n=8icBoZ)@}!@23L5JP`9voacG88JNB$#k!G<7`k69KIF)IEhiT0%vpmmaN+s3VpV7U^EmBn z#;^UpO(gWhiYjit5GaS4pJod%6z@ zu7NkpNm2Sn+oeWi8Nd&LmAKqiCs7qOOD!=$WM%d-YBs#Ow3{9 zo~5-4CXD6BH?HtIT4A(g@FozQh1oby=mB9Y+FLmkk4ZV`jKwF`(5-eXVl1~}ECc;l z^WVoYED)iHLJTAl|3*5WumIcJk^8}Ku&k^8Hi;T=gP`=u6@qcT+qS#FAylA%EA>0FX0QU zY)G$cpQn9DMzS>Waaiy(Gw0?>q#5GA*xpW zV2YIPJr9*{INLxJ-%9C)4-Ds|=hK%@!?4|p!lqqe9E6j}Pk!UkApC_a9{jhz1-Ylb za{WC$DN(F0-kdGsG*LH9gk2O*YtD(>R#lEYny-oRfkIVq5?{J)fwNR>qt-n|wjf#V zByr6|I-TW^r@tdd|8W$Itu4k`1+o38v`l8m0QU-Lb+h!$#Yhb=;rD3W5UoioL8 z?nBiou7=xUnI(ttTjMBymRY(dG+Z#M?PQPtGq;^MDN8TD+Eu8S{Hc!;UlP%$biQ~s zI;>#cv4?Oe8K?!iZ0?qeM`W`lQQ~Q3^vfVa2Bs zguG)e;aXj!ZE4R;TAKHBy+Zp~$`@*FR;1_sU|fVO-(zvf=edz`b^jzt- ziG*-}z;7+PNdp_gG@JPrFL6qI9RuasOmd z8^3sa#x@W+7g>$`4-5g@A=d$E9-9Evjv2M$KmAr3E3T(6T$(Vl)`S_Fy(MJ?%eV2~XyeIcP zq9Pd65plP!^e#^zY?XRY=JvWsNq-EQ3EZ6QW9e@pmEgdL8t67X-=LsZFi#g~{RVXR zScu5?2=pf^;?@aOP<2Zdgxd&E_8Z$`iIPYoh=z3*{nEFw7>1ih!Iy-13Sw>nlGz+LJDoqwQ&yyH)>0o^w=gA@Z8{Y%XsZQAk7{IKnv) z#Peykv|sn?zc20k9C{fXQQ~$Q2)dorv^O4-43!8ryf{BJXHg5+8;F5t{|e94Qpx&{ z{PKYX!wG|?YK8(LH#{jYxjURXRY5c4LG1iChfv}Dn-c!AUik6m94A2WAtbdY`r(>_%)2LYZ=kWSe-ZE|8Cs@Z#RC$1Zg)LS$Q*_Z(=2TT-bj`j8BFG6uzIF5}@DsBgO#JM^D;o?NDW_mS2A)OBej}Ph^s0 z2v!y9jZ=TFMIPyiW6sbBkk-Sk8Dm8y>yhi^yLa+Gmc)%mcZ}#3H0eJ&VDZc>kbi=$Bop${vB;vsY0hK)$E(LO zxH}0%v}c|9x!_71$dM5|9KA~*sHNigB9x0$U3fGRcXrI+H7{JMGu>;W-cG^tg;mP= zZgn|3kHD8SMKt*wCfNp}K!G8RW%-UOjkLpLp(u?4`>J>4uWU)OvlOdcBqcmq1!||@ zQ_Uy(+W)9I;1=g3yCpj{vW?O!!mjV4%T-iAf?Q~&oBPYSvzS+@WKebnS9Ao1PVu?Kcp{SbIcByidkc3D?S~v8je^YIRZXmdCA55U z?dU_d@Lvxj5GtL?XAeOVR>V(_)+wmqE$NDI6Mm{fuL|mH(*I2RW^Mu;_&5t$y^$bW z94us2J_WwbK=vaF9;N+%cAAbE1C*_-It8@qw^~-iWs%}_UuF^ zB@7$?cFui_CoYbZJB3p?KK%XuMI&7+*>+Xxxr-=V^kvLw9Yd|$Rum6S%0CY*nhy12 zI`hs>($L8%E0M6mZjF5>w#^uiNjv(%cc$+@S_#kVpRxw!2|L#JAC~Ns#pq7iO@>f? zPuu!Q+&s#@`nGYoVY1Vsn)C@mbc`VL5BamwoWE?;89yp=h?%psi;iG07C%;qG5{q~ z)t21&&GM}EriveRBC8vV&P_7mDlZtUOtGVl^eXvE38ffCf~i%+Xjmi=uu?)yMy(Ss z1ji89EG?EoVu)aCU;BhhPk`1R|J_ioSmj#=4~Rl?bbA@;qrF!mH_I7_wH}>+UwFxz zY7pig*`f7h$DU@@%TY2V7?)aA!SQ`7FT-eW-mlY5qd8kNwpI&3MkEQ*D0)KfFBbgf zwiC+=#-cOqet=6u)4=3uHd@ECvF~9=MfD_6Vklu=kGtCMbkw6gF_>>&MJE*PTLfKf zcM{w*<$__Lihp*V3$2dSe73sZ$#U}{$`PxpVp=?2k0WIn0=RI`m zGVU?W8zyIb(#elnmSIEgE5AL4pI2X`UOdy6^o}Qj=KsU`rI;47Zr2t$`RiL^@qR(K zVE%`N(u+gH!SX|z+HS`=DfH~+>DTDNEIyK*ANz~mEy_m5yu_{VEE~WOp6_*$Rc!q=9ag+v%Q1K%k*+rFsh=SBiFDbe=|Gk7@|O5H z`Fw3To3&~)?qTCoW&z61CfD6pt|i-f!U%)S!3*Tyi2v-WD+rt~Nx|dMJ9lHhxRmXZ1#(|Cer9M%BGsi!qDkf<{$u*D zWjs%b#BbC$^H8rK?hW{F20!@|niE}p)Tts%|27yp1n&&?TESUnNjT9VV&oxk(1V5y zhZhRR8T??WG3qdunWyvY<{VZ{kI4)q!_b`TYLsP|kNFNSf>0ZOfvt4qbBI?F-?E{# z5{1~jbcy3(adFo8xV9ANdVq8{@Q@lw8PrwS_?N_85UN8TC<;h<2Q$zx1e4*rudW97 zs!R#pFUQ?CJZJ2}6Jn@Z;gjc-m4Bd4XPw1ERTdFxiK&9So1^{s-AyKc8NSp<53&qL z&pTzporJ~0MDSC;Q@#M=w&xNtQP1M7Cn@>wLEW=HLZms%CWoryT)fHgebTLPR0L_% zybZ$q?H|c7tgk%dc%0QOh51FcH;cwwLYf%Im5t6*LZd$7(FgrI;RD6Lo}atwhSSeQ zIpY~Edro?LQ*4{rT8f(hLywGTLV*DooDKRqFb=3W$TQJ+6c&%<3)?^b`NEU_rnQCG z;+8FtXxJ>amlcZ^$_nW&_jT(R+qMvWn5z2i3Vb&>kmLOEbBA2JW3O#S_U; znOxfU5}TjtrhR;#@9Zu2`M+^!sQ5~1W?W%s$FuV;M|t`pk%NwMg3CTfD-)Y0UnU;T z|1qH=g7L6~nNQZsGC*t9(N5;4&hdA}^JP2xM}<7!mrjxv)Re}wSX)DghAn*fPx{88 zvle7~k899lPFcnxx26M4++kCASZYjC@P2Hz3=FS+_F_s*3V zN)+n_UaW{OX)($Nl4CULrWa`l9A@bP9jpv=?c5Llb}~T~S*28fYUZNn-2%~Rs>a3Y z3?C~%N19X1r`Q_=`7*c#hfCUHf4AR1^8cqA{l8XG<3jjgBS5}@^Vg4~Q{sN4leu%m zm!YJacQLZE(0j0CBK&W<#HsWViK<9&n6wrdekN-vf|w&9F_r#QyfSf!fiL^8PsLN} zA_02N4}JEvg)<9X3-m^cMqY28`eOB8I}c)}v}mRM%XyH1C!kV*1BNoWqLXE^y<_2F z?7)EfIYw<9tAD5c%9t0J*_RO`7elU`(dlGWjksW6@B{uDJhqxUBmmbt)LZCe-+rB8 zQTqS(gC;50kB;xfGpS>SlRm>v<#WMA32OWv%9e%!N;34yZw`3hg%3~yH<;P7Hta8a z5!cW5`RduKgn55w1c)v6#)|{{JK%&z-U?c@34?GbDWoDP$WfMRospE3KA@V-s4tOY z7PR<;4;Z-pa>;aB?%$>)L3`$3yH|c?)5cVKJwℑ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 diff --git a/web/classic/public/cover-4.webp b/web/classic/public/cover-4.webp deleted file mode 100644 index 0e9ecbf0d206c6b1079cc82691beecfb1ae73970..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54144 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 diff --git a/web/classic/public/ratio.png b/web/classic/public/ratio.png deleted file mode 100644 index 9c7e02c86093d1d373aec66530cdaf77f5100d5d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 143438 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%0DOxK%}YD8q@w2psRVK>Nr!8pA{rvzobN?cUBB_ z7R^JO?jQaxV+TsPE_Wvw(EgfwV5WseOdk>I9s1nLtfI2DfS4d9HZ(WsESlvV#{7Xb zkjoP>BTxLkx2qfLkd8h1zn;c&XI$G7NBVPw{nTX~G+@N=~5*9uQn(4}- z>k9EhaN%z!!A!AZ;q=rpK5O%Dpe3}xVxPgtVq%CE?iE2^l7 zC8D?(=lJK@pK4z-(o!uVJ1#0zWs1L0B{hu;N^Yjmb~9T+pq~>bG&kEe4cFz9Q7>pU z?hqJRVTkJOFi4@Gh_x?qQu3}e%9_L3uk7`p>ttcul&#|s;AN=2(OEk1I&FrF5-;JVvZqYNt{FbjgHhHy|cU>{MH@Ihad>|f`T9&_SibP5&_PCE#keb*= zB~QjEIDv53ebU)}J*9S4og=`Ruu+s9J(64*ze+uJe@d;uLYqoGh2g6KJ1*> z>}9y!YFY(i@~e}qR@ykg;91*ivDnR!cd@Ng$pJy8;cx1b(1qL$ie!=N!*Q6+V(=w} z7;p9tG=+v!@#nTHE2&UrF@%ujwdDv2xw6^^&W}3WRnd-m3Tk7$l&tSX_@hw#U$aOD zPmZ(0s!WR4nU-93&6+Tl+qV#^OSf8W+s(TgG6;V1NuUv864CsMDh%nGND1)2zb%i; zS?~$u7wyio+K<|2>J{?4`Xc&N{xa{WNk~r_jWHhRo~aCvITc=9+Qx;ZiUu~>s#WrK z1`;lmq9yet1dgUdcdBRl@Z_?*9|Vc|%;z&c&C8dU*<%&Zno{nVz+awQIZWogx?-kc29r2s0A zq6&?g<&Vb$BbSFW2d&3DG$OP0a2@NlhQ7~_%t6>w)5&_0UKnO}WSS-~RlVTyVsCWj zG4bQS;oy3`PQo@&e7ANr4Mh>ueR9mF7|zLLyAXL9B{~xlHg;Jd9eMo#cm}31@9#Jz z=w(b{`oR`=MKh52^r$dyek;gW9xksu@0r)jT%%;_-lKFe>kg9!zpsgHK`dY>{z^*f z;pitjsYt=zip$%)2B4}=d=1K8?U}8ji=B81--`G#GVWzlP|@pU#$`18yFZ9CwsvhfdM;90EZO0rpK9uC{OBHSw)}nOdVV!A0`7dyfG zcdK*^HZY0O9mLDgc+#j0Z|(BXu?eVvj%z6H%cLSiO6IumgBS))BL z5V-B&10|(q<4auFVgMotIJfORk$@jrB7&YPoK;5ItUgf@M3Yk3s*_R2rC1a)F;K=F zAMS}!h%V_Xb4m7w$`W%mb56z|7hdofygcNaV=DtcGy@1!{7!Q^M>h>mJeR`b+>=AY zZxHcQ%0$0uk`j(cfO}`nJ55q?>z=ciTwwOIt;P%V^AN3;RWw6$!-T07A4`p0@-r&Cj(^aHhily% zAVg>Z)4@yGl2SlP+Q8bG4xN4>XIW7KqprsI!RKZ4G8ahNzycDgM!qU)_LnzEmEy)s zMC~LiU@5y*RKj}BcD+VLK}xgNby8abCh}T3E3%Qn4}j)Pa;qakeVA5lRvA&KWuy(} zSg>sb%rS;fDnG}HSI5hwUh0lv9_m}~PVn&E!lvP{&U{rvXiFwOw!L%T&e8gjw|(g} zJRT1LGC5WCI^3G&anSA1w@iv>AJ8kqd-O&L%=@J>H^*@!j}uNPnVTK}om+j{dG3|+ z+PcO8V!r3dWk>QP&=2o%yrhY~>W8^@>D!w0xTU(3=9nUu2J@osS>RVT`&n1NdH}TR z?U1M%M<SD}P15hKSPYkd%^YIUYfS?y2B?k1S;$OU13+A0oR zNE+|nWLP`z99_Xck+Ph-KTP(`DlyZdv@I-7^4QLEAU-v39UjeEWZT|XIvoAZhwcf7 z9?Bpj{Wv{eSTw)Ai08#@Inm(7_7YjNd@q1DKI`z95;Ls1p+i>WJWRPEDOd0qRE1sW zK@;~n^2ok7zDQSjC9Wcx4**QMA)utx$~|Hl;hIXhGew3mUllL* zra&wp+7p3@&jI_!JQ%ApcQd@}?Y+I~GNQ6_=MyqWo z3Bi3a*#RKWg*{ji?{JKK5;8H-Wk|Bf#k4q^J)^IM10cR>8Y&=uqF5AK&0Hy$ zoB>%YclEB$zyn6EfqB73P7PFSOJq97KO{seTr#jMrBo+rV_20Qmo9|MyImsbWT1CC zw}OO25qehjrf0w#JYe|-O$Nb9VP7}1rC|ZxBX1(l7J2t{sO#9jRs*xb^Kh1EIz=ZRjb-a6kH*R4ZOp8K6wb zo5R7HZ4y%WhvG(zsI6liBQALCJB%ADTK zr7VwF&;I7G4X;!cfRXAhiNj3g^7uz(4Jkh~TCmNWMr1X(&Gjs!v>QQ^0ypM+IF|-_&8uH`>w#ptJ+~btbBU> zDnr$#7$gj!_&gE17OxpW(emS`nS4vRCf7!EvY%cQXH0yMWsRmYjVrf+vwaOjvB6YrE6wv^sH}VYxy6<2H3#7;C)g2|`9S^y&(PXu5(S z!$C8+zFs9dwdlAyn zTW)Fal6*X>+o*g7e4A2Le|^#sxh=wVS2-z-Ywy&;o?Ci4Bh%ea#`q*Cs)!hO<|D&V znojSoA>#6CiLDLOyJUBhykwl6qwo`3N*bJEXCr`w5C>Rp&o+XRQ|Mj%N`A0L-|%n= zs%`Y-31$25ix=AhH+gw+^HRfxnFQ=rbFpm{YFBGwoiJe63qc@BOph9Cq zE4ip*XcWG@?#{uj!~{%7Y6WyiuPD~MJ))?F?guR&1O(Bf+y++WvceY0hQ4K5LoX{# zWq>cTTB|B;EV(qvoQ%Se?2=Vs2dtr>;)z(A?hwqO5;!V!p&ky224*+Y#IiMW1n>2& z)bbeDE3>Bz*#}VAUtZrbUg2`uE*}1A$+zM z*YA$^BxbIee0eSCEB5CU(_O^@Ob4euu#ffAJ`FPp!n6#Km1;_g)2n0NGSQ`L`23u=WdN)((;oAuPtf3fwW_*h^iB`P4S=W zk#t9BAST=8awAJ5wV7-16JWkSAv-?~myAtzB9DEgib=-|HFs}9tDDu{5kYCfj<@F1r*H7N}^t#qj zZn~}v&?=FUQJaeJh$Frr`j9I2D!wK(Fw+?&GCjeFINv3hxljRUMp!{g;SVrY zJnK%ITE}m8nf%^V-G&=SOD&c3J@FWar-&iy`RBZfU(2|vztSP+R3l0Qj)w( z^t%JceJq;g+NyxoD>R?Y9J2xTD!ZFbHX7z=aHW_#jlc_v^!6%UOYEVt$P0EI(84n> zaX%U+i2?5Wwm42seTKOA(;=Y)-SB1{OLIdGds<=1H0sh0&~uU>byitH70^4>v=EkL zZ3i6P;1#>>!(Hd@4ePVHph)YpQ4=B4oys&N!=G zS|FH@7&1hAD-B&EWW99J^jQrk)=wBIHCV+57NyO*tb$*t8FC?21W=ITsZmu8EmX`z z37K_W$+j0~nV?GwaWMPQ;$e@yj8p9i%$}z`J+c_gWSqJ@dz~iywA3Ua#XaY>f{_iCfxb=Y zesa>lfLh~We3>4*@e!qS&z{s54BPeef9oKP`pYhiezRy?WvYgbyyTM-)`|9IX@xdVLLqgXEs|T!@w3B-*5Z)jPwW#DI zzQ`xiN>|qRW`ay<@2g5pD@C^XH!ikbs2+D4@NrgpTQnkStLEtH2&juA-#iCIIulXD zy^agpH`wFIq>fxIhWfceSnq1rQR9UYSDPr{N$;nby$c_w@iY&B3LgFyv!``d!_aFE zKj*3<9;Z%d$@WGdkl! zt;y@l4+lx#NSv$QSfxN=Qswb$v|bEo6Cb0BX?b3e?TjX<3o>>gE^Emnk9{|d5V!f7 z{GiYTepX4Pj(3n_C3q zFl(HtVHRfZ+S|A*s_m#Ek2X^>d;9}KbwZ0jn3(4`X(1+^ z3SX)4JTNK7>#QCCg9(55^)VtRp)+&xX!u!uug(x@+OEv-D`DS^NiI1`8U`B2Y_!=v zH_h4VI;2U4M&Py8F>p|e1ZnZ$N14o+(FbF6&w(dJG3}~Mcrax7B4RabsLWHw6Xj)+ zbE-wTKKsjDQQn5Ije6r({+E78F_5G7`^kz2dWOxWU3?>^KmUfG&@`l*;0mz$IkobD zl6~M&FNb|NE6CxCg;D4_t8y&e*Za{D@`K+|wI8V>CTS#w6dF1l$(hU$p<(0;+2@ts z*age(X2&z35w^>N@18c`e7-zq$Oj?yt%eS`5QadwN`k$@PQOyJsGkh&{7Tmg%oM$V z&YKN>dvT>JlfpB&x&2`q?-J@B-w|EAz`&$AWk4|hI+!@OljMJze_xE7@&kw)9rU2@i>B>nowHIyz5HB z-Yk>axl=)u<9|NXUTq?H%(UmUVnaC~v0c*Fa}@ZJgMYjg#9b+Ve7x=Ql|76r-AR+8pg(dYpF%h}5Vyv8>q z%05H`sN<1{hz(1%9_fW~X;ocx%KS>rBeRUci$cr9MYmRht-P>>}5D) zSNvMhn@d#UA&wT4sNKmaOPl<9;W^qjZ`n>&Vw#aq;s!Wndd1>*S0B(Xu$?(4(NgVP zWb)oFWOd4~;ys-cJySB(4}Q_jf`=5N2OiFy31NF_UD^RE96hS-jpL4BnL_vH*-$30 zd+tAldX$?LXw@Kz8g`Ncyq{tVr=z zi%?$CVp+PCwm~2!NdsLm-|H?K?dvhwnwS&aRofqms(J-@R$%|ODG||-t7?{R74A_v zh%OH~9uM^1(Bl>?mI2Rdjoe#Rr+?)Gh~IHB7tFTx^QTB&{t_ZBkSNtXpz}3}d)l2e zofoqv03%$guU#qBC(`bvS-dG}rH1=(59Mcy^119ma9`l zb|oQ}ha*?o83zrtv_Ahh)^g9ygs#&?Dw-bv;qAJ_N70}OXS+U{79I~f=lZBQO9AEp z{Y(n!W`wttm0XOTRk!b+W91kQR8#=rD)2G7h<}`?D^#?lzd+RNM~OJ3Z);Qxqka6_ z^N?_#eS8edBGTsRTW`?4qC0no?MdMG0rU9q8N^^VJ^nHas%P#oBstujpR(;52s9)@ z>M^>RpdEQ(wLC3E1vxK*Z-3tcmOO)^OtncMLhr%_oi;f+S{>o z=C*&VDMxDDSu)boc+U|7eW05ymr9Y%i5R+UMvLn8ITl{1Wd6@TVD4%?8wm`%?}-ax z*M0!Z(^+6*&^j2EGa+K0W-6;u#`%Zb$>SuWS?y||$-5N$z3!ZZBSc)q5%DK#mwf=3 z-H_2V9ODTnC)-Lmc<|nZnc1mK&bVaU!I-g&??(1U-n|How9?t*|5)s~O4Zs&O)At7 zjA*#BkDRF{TI}R)+Zhf#DCA84e(pI~9ci!OaxM9}sbuTrRONTO{TL{71Xcg#1HePG z9$^U=?uq}s5F&Q<91?bCT-Wz@xTJ z+_(a1dyYEw^#Sz{u8D>6Xw}%JTBVpD;thNvxy?vcn6?s!TA7koB_f$W+3g;CThJav zKUr@G^(H1HmU;$llwe8OE9CC#aBzPS>@kOcJD9donzl(y+W0g>^3~WHA~7uK8hAGh zHZV;PyQ9L3YprFRQ89DcYnUroFw)Q+05AFQ)asl#>XOB&nv42jl23mN_u(9gwD-9{8H1( zI%ZdF+nB3uj5l2Iy34XG?0sQCM>I;Tv7lPMkIX%pLyg*Gm*1+VH7!=Z#0^zN_v7Ne z#v)bIuBRg?SrArKJGWTHVGb9-DY+7VW`<|p+Gp||J!2!Alu~}(6?Go$pGKz|3=F+&Is-fIrc(pnD+cXEwhvbevg3w<^cE*Jn&_`(NTAHd^#a?EYQ>dC& z)mBuN(`AoWc^B!7taGIr8xW8$REe%Spf%x99vCup9V=In0;h2Y+(tOBHH^}FuD(dv zz{EabR`p_Lw#$4qJw06bINsR{Z&kf7MA>f4%RH0F#LQm$8{Uib_u3)Fm#vbRz0S@J zN7Yyff*j?iDcwS?y@YZ#uyGxjP~ysu2v-Lt)C0b?$l*oo9@91P&dj2w?s}%Pz)!&} z^Tf~?(CR4wHPA0P*(xuWW^6=yuuI^e3>$ddj*nZu9L&!KGRBrMDUVgNGd>!2N%YO$ z^fROwn~AP&q<{?94W#UV#fEJI=lCGK<<(<>y2HvWPV4|UlAAZCE9FEVqm|<)aIoCf zJGmgw0b+#Q222%BbT92>$h*Mm=O*BB8p5=$1zbFYQdrc>ap$^ z=soyk|EbujkQS3MAB@ytNY0HU(meE5&2d$KG|f`sNZW6%ZLsUIATaccUiVB@$(uq{ z@pn8|#Cj8^R%}o$v6U9N|HIx}2gMb9|AG))gWKSP1_%u9&fp$g0u1gh!GinXZb5?U z;FjPnA-KCFI0-J<`Mr9zf4sN5Rl9$@-L3lGnwq)Y_uiSFb5Hj@efo1gN8;Y_D9UMH z*Oky@|NSy+M!y+3I6xeTMlgzKA4X#M6llCOte!mMa9VHG%Sjy%P9bZd!{YqyS3`d5 zTjZG2+^^5FAmF+zUVTNH`jR-X@2L5lklrD{=MhE|9~d?G?c!wv2mqQRD12l9jeku@ z89t^DP(jum-r4cw8lc2ZLu3r5rj)=Ni3v&`pfoUu6&=5a_?JZr<=pa}eFjB&Cd0tI zh=x-MD`aovfIComJa%LY#9gY6;r4iZ3m?2-T~HYlxvAqI8@qFa2ikRg9C6u6%$sZ#;u6XShgPOYrMX}z!yudbi zE7E?*i5TLwLy8iv)oAx^;!tZM`Nds_fgFk0iEqWxbTGs*^FX5$Xb~y!hOlhMvh0Mi zP~;-3X4q!)a1S3O507SF?OMp1{Z#b9laan_tan66K-$1|PCj*vhfoZ6Bhmn~mO$~2 zj!?2aZ@NT+L$W{YJ5ZVSA1+X`-I+28yEnMfu`IimJ2)aa!;KxoRb0qWMGL4kn;9norp^g$())DkaPKm`GkVq8dsHkN42j_Xx_`IF0{3I z^n!CYDYg($S0HF)VyCak=Y7;P^o1K$QPTqdgK-_&Yu)alMCKU2gGeA~vg(vw(uxqkYrzr8qEE!ye-SPsy zn64Z_QMoEUiVn)X$%~G1{4*>9N__5Z)9f5}r=m&5_8sienqr6&9|Ut_>=g((hD9oS ztg;Z>fc3X%nHFaNWu8|s3yg5@Ub8>LC<6>a2txdX%5*nElUKX=rzni41p*W7SF+n{ zU&Cr-9ncE6febs5(^i(d3lPcgBRCY;53KidqunL1UCXX=L&rZ7WQ_EghkIp1Kj<#T z#a;lW4H7w?0_O?-d``nSRnO##kR-ybtr(&Fhd_et$JXsZUD;iK+OHGSTjLSLP!G1R z^~k)hwZ+3CO?i7!)#Oo|&A!EO8??n%M}fV$jN2cOvx*)8`|xq*ep^FnRrix)#1HhC zcmdx7W01Vw?#O>mqkm0@06{?1(D9CRvu^Jn7%W~37D_@vdbhT(h9D2+!pICZ z>A@@{#v*H8NMjU#C`|IB@`uZ5+mzi$DK(1Oe%0Iw*8_$KUCNS7Fhj94mo}`g5QMCu z(~)+g!Vfx@OA`Jg`yQasFRmOK_6{lR^DpRb0b?n zrW6XYqIq)(fhH}wBCsW1`=o$2;?bOJr($I57u;h+$zYub@aFl4P%=kmB-%C#1L7bhdFQd5J-Eym*Idke%y(zb@Y2mhX zGQZ3I((<)cV9if;Lx0t9qZKT>jLkTNGX}RRkxH9MpX^-X4C6}cX8-8=#{DtIxekVv zPIep{X4Zi2KY|c_FDLT3)>c?w+0G(tg2O9JdB|Y>*vR4m{c2_GPU#FGuuv&klLJzL{%BC3S4J!zskIpLLjD<_*H z5#;1rHZ(+`B@j0IhcF6!eWxRnaxDw6>UGbypP?9rUQt4bv7hyv?pk)?u}AZee+Ysz zLqVW2U{K<9jtb;p6l&u~M<~V|UbNq5O)9Ra>o0H`)8aCv<6$cP`&Kzob&B$I3 z+=?$YIHMW19>IG;51sjkkiHc-Ev@u!P;i4Z73;oqWSfQ_R!1~>UpFM9#TpW3%wo{? z^fB=z-%$Thh@oMWL*!m7gmm>%tix>Hg5BL!#w;-9p9RB%&n| zd8CBka$O2)_Ugim+1iLadQ1ISU`)`F_%|e1Dp}z4H)JQd>d$(Rkzq$OBy>$eRphx) zB04!Svg;%iM}j{^+NMx6lBLt>I0oVQU+3}U(p}~ z2!W}DOur_PUp-zlOXj8Ae}nZjMgKJG_()tH$`@X{om1-8&ZJjJ*^WMMggrQ&cD#7H z)7Jnqz{Se2mJwGE!m^W-*1wX$$A>OQ|3sgpgHE0nsGbA4r=ks66YaP%kzFT0q_`$% zPa3=60u>{6$rhnc7#h?3b_Z@X@b3jo*+JxiH3meg9J{?ISViFAO~; zz^W#B+D-M9#7+6)PN{F_y{i4@QSWTeb%USKSy;U4q*{Q>V4h0QXw?-x=(Ydr&7t)K zusGy(bTWzujK|Hb10e(wqOxi>%DPm}W$rg%E!_>WDq^%xLM&QO%!JfRO{ zO+ActICCfXy|)DjZtVl4JVeOMHDT=rVgt~N6iZ+GnajGEQ?XNrIeLhP1dD~$T_J<- zY=&lXo-lhWI(es3;ekU!yfyFWt7QMOY)0^C`t6plu;xF6CE6jYg8?3z5G-k!!rtRb z%M8hP0$GF_fyyWZeaWK7;*L+xdaWfPAO3KoM=;SBzn-&Pg$JLAomqq6 zKFUh#cqWu)>~apmg5|6Do#hv>GT_fm3jgS29Es6D6x<=g^qAG7qDIZk3UrKjZ4{PJ zCgPHR`%`n}eem+!uj091<#Xy~g(ecKe`uP0OS35k#!KCpf25KHNj%DeCk+#?zLye? zkw*43M&&*njCtcP``$&r6h3uLelO_gxZlm)Pc$dhBd^Qfl;d@2Yuib%Gf%4S4u4Rp zyMkF~sZTKn*HK7bx#}g&OVj?`6vp%?gmG9sT&LQTSo5*$kQE8u^Jh9yIc zw)jW%QV(i5$0jJ0a=}91B=%9IIH(GDgbg5pE{75F=QP7AW&{snX>T$2ZE*H7trIyD zsqVq#kl+O05Sd|OwZKi8U0FHgZ-UQ?`{H~+VC!>duOKwk@R$K<&+LB3VVhE7_KR4a zt?$`Y1BlVdk?*u{1TjNJ7y3gImrMTjyrKH@16IxF;+pEB2A9;K+Bi{H)Jc8U3~^CM zO3*?{JLY%=TmD0Bgk-tpJG_XbJ_~Nt`yXUBBT`rAmDJr76mHXX^=}Y>z>)qOS9?_S z>|>;?yr9Y8N1W*w&t&iSL(5&H7N;h0jsMyGe<%luyA-?KFmivx(S67G$t#PG7xG=$ zw*0P{@2hwsQsj8Aq({-iBYUPGW?X*PB^g4bqv1sbtd~t}Ao|UXWq`%FS9PU~mt{qoHO|oWWm`J5a!tsEW zo6~OgR$Gm5m?lF<=UD{d4G0o}vtce`>47wu&=PVF?I#gjA(E5GlTrzhb>?B%@^us3 z$1?>cSv2zGn{Amz^2T>>_9@t5zjitv;X~d`ePD#V;jYj%RAuL=XuBpb@!L4|6Fla-Ty*hv2&@9*e)R4=I&nY0uzCSxLT184V*8HoN>KPrAhXpe@m;=maoj zWv5G#fQXwi@P&I!aFl9}p`>{zNrC#8zdB~o53}S0+F9`Lj2pzOqu6CkOH$hFC_cr(Z{-WHHQ+5*4rvRj(M#mY3$V7nCcV`A?$W&?2&(Yq?=K+n> zYkKo+w?PBG6l4#DajwI@vY=hC}i>F=?Hj#V&~X4PJ`sm*dg1FgaWSuYN?z;KS#$3RKk|VfVl#d zWqZzN1EbHua(O)t^#A{O=*WqK6ICWpUmA^UxZj;xYje0|sM{YY3c)6VRUX~ji0v5#LmCv4o}TNYDKKl{Uqr%DrpfFMyW@t$2k~F76%K_ zl@I2?M(%PhoI=2(qKgc2A|~v6D0EkfDDo{0BakXmp_E}rAqKN5<$3nd2u=4s;iw14 zojc%k&8sUB@_mJ+!_uoRm3D7^NvN(5V09yv%|m)o_dgpFg;7xER+Nd-k_eB+dxvF~Y*Q``1~N zPni)(yYQi#bW+BQptan4(-WL~FKFwm5RA;?0!mGqWb@wO#1iwarKJCp)U z^r3yYQy+xU(5O*pyW-}aX&A7Rx%r#O652g>9p>G*`SR@HLA~RXPTrozwk@zw#Ucs` zV{Yj&LZAca#QA7=X;;vIVw#ki!~1nw)Gis+F>4E3x51(2KbUlAZ0s_e6u<(?WkM*J zA;w&k+^7o!k?~lx`NU}7$VzTTUh*>`SmaAj>srgoi-$*SLc?$j_X{Y`=2soxBSdiK zkeR5DZb>I^k8J5zM`ukJ+2hIIY0qJvItezB>~g5EnhHngm@djNQyqB70gK8T3@XEI zArnGq_`I0x&Q1{hfwBMOvSG9qHSHanqd25y1gB5G7b4A12xQvCMS~ich5SS4sLjtl zlc^loa$yB1eu<>3#2Rba{GQ#!5}-xskeP1>`LMb(F~*OuGLKG13==nFgvTy??f6SL zuCuaOAx$A>2I=CY*Bziz^j{Hbl)f5`kJk&{z{gI%)|VdH_1!>q92o{?C&uk>FTkVg zUIM09h?1H+li2o=i_jiA)~M~yII&#KWcDsaKY0}NIaC|j;5dK$=;973wuzS+7Bdpj zhd7f>sH7ja{6qKxqw1Dg74;r`Y(&J)kw^(MLNi~uQW}*SJpBtBMiX}jhH#JiC`jS= zF5||+V>^sKtam3;ydgjgWeN%UL1XSBtG$xGw>)sjM7C?nHEdDVMh5+dFa&g_tr#Zv z+56c4ovCxsT=gOD=+oPqA!3Pyz}%;-op2FgoZzw_^&0ud=;A99s}^ywr-%oa3lti? zbkpzHl@$e`&&_4d1{!M0^{w6>06-*2_odXF(YJ^&lz!&&>#I`9ZQCm;`Q%fvhd3{; zvRo`VJxOg=FM|9TmLF2DNY;IDy|?S)<`UWv)d8CMZZ_P~2+luzFqQbxxa>h|t>BaR z{cQ!0$a5aOjd^;;Tj&f;8_qujP5ZjyJJB9@r`xvqv&+p&@F~yZ*QW}FVLw0eg;E_|V37>1PbUEInG*>+yNr}H3 zBVPDy#x#$VWJW|eU?FCQM5g1J*XVF)xo}fUjrn+og`(p}d-RmkUyrOpY#=MTx~zz? zAg?yoo9cFxn3VkP;9ynHZ0L$1<>cM{RLo5)VS}x{;F&{d(#6md$c(aq|HBN9*2H*KQr3XnP(Iv0n^w-$Ey|m*Q1D(xjb1ErJ z0wKi=hQB~q_A?3TJ7B!;1Pa#+i`xRUA)|-mXN?O(8Mx4}R78li@4hqjj}hN9Zd_S* zs`IF5Fxd{^Gfc#knUIsXA0Mbdy1Zw$FDY5)pT%C6`Rr6D-Z5Rce}d%ua72iebt+U& z7*c#hve+kL8CIY&T`#be0!<}s_*Il7JY%k*0L16pA_)bbQGNsM5yZ%tF;s%A|X@sVIQzWhO|JOs$ZTZ2RGh+$18Ln$zt z%p@s*T>RFj-5bBVK~2O)AIHyyiuiwcNu{7$|^lKLsif1HA8zo>z& z+><@8gL38QwmoGvyKd|F`pDsbC0_b}88G{Q_@VjE&dn|31jD<_bDm5*>u%8IMSS^7 zn0#LAuG6GrqQ`i9rl`kN1_o}olDr6ci+{V9@U~Qo(--n-$GR3(msSysYp@6Q*<0u* zT<#LDQr~zp*g0q=NRBGf0&|Zco%}2X;ffzI3=HzLkKpI0@@T9KxM@>MqpI?Z|Stv5&9j`{jAXV8pQV1>12q- zt;ChzZgCGfPKqdJmqcI<5pbG^KxO;*YkZ#2p;D^Bt-Wyq}Zo0H!ILrP>zN%rpD#wRNv})k~1bPx}e< zzV^=>^czjjG{v*Ft$V-n*v)MyYDti0glwA)ImC1R>m^}iKX=?$VogTL?tl`LQdu$> zAIU#Lb7L6DYsA%fGi%$d?+p68l2~RGUrS`xS-pSGaNFZV}*e}nsPDDP4 zZ$Xv;c47X9(DL6%-0%_Jyn)lWA)=t5pu;KL5D*aGARyr*6VQPOrHJUcHO)})xHQax zk|D``JkoBTiy9hdWn7c~8;KhdBBBK1KLp#~u>)4QMWPD#Y{QUWbDL`gOydDTD&%1R zwQ5cAVmnq6q-50mH)n%&U-qB}e1s}^)Ej)Z%5ZsVzWwUQvF=`a5d3$1{`70_cA#6y{Jjy*Y|a#O z;%&@jVI40;(Mv);dF@XM0I3ecmj3_GW!GLPO@Ab5vx)TP(p z`Nc;skPM4!ue49^@E&LQcUctc=4RP287U}xOX*`vj^O{m0)s?lhH)9U$p1cnSNHpR zhkjxTtMpP;c5>OOZdh~&+G7aCR9TV5R9J2Q0cdvM`g7UqW6RBRC&`xv%J7SUXtbt5 zG#`x3qWSnk;s=}SByZv>Ey)IJZ(Dzzv3&;3|7qQwIHA!!B71h>?0`{8_N{WMpc+*e zp$&+Y?vWAHN|!VG;d=qe+zPBR006frt?mgIlsk;GphFOO8|ABc@q(`rT4m*2-B$?% zE9=(PEu)wGn$-Z8`9<5|B6pGauWD}>{~_3%h?Wd*OnJwR3)H*Dy9NvCymtj^{dSf8 zWcoc?ozR9XQF-YqaC63aV>w0@%8lGNP5V<|8q>&o+0z--oJ{a}h_Yy%DfI)V`eI;F z@z#^<#a)kZ-JFotq_dxX<1`nxX)l1ZO(~M8Jl2KZ|Ys0|b-0hn2ckJ^~#!g`$wm${4n(WB4 zp_cdT(M~vCQ}$JA7Q8UFMFL$)DPVZf=f+1@Fm=~P2{#D0wYmlYtzd!8!?48&6A1(4 zk19!h|FW2*40;u5i1=K+Q^&)L!);Jb`CZ;W1awsKi-OA%eu_)B4DLC9ee| zKE?gYs71c{#Q>g_Ex}w|9?Wgj&m@BQ$!XiewW%pgh1$W&lgpE?$XBi2ds)Y0S8sdL z>fLO;e~aNpq2e%>=gHTm?miYu`U&NJV+jj%8f=k|MqJL}mbv7?3~^6=9M-)|g3GpQ z6OXWa_-L%{8!$@b&9k<4x<(m}PK1axHtIc_M^%NR z-=2-BJCR3XS?uQX*Zdxcz+|ixck{+XYc%swE)W`zzl}FUL}1W5Xjd`LpHuVP=KhO2 zx5RS6oGyElT@(pjzdFGs<`wjSy$i5GDPtt@9m)tWhPb>;QH$pFnf59W+DCecD&vEN z{-#x+OK9^UtusHW^1m%Foxl3UO6)s*epu*yNv52}5sne@X{`6PNxfy5OQL4ZN&Fn` zDptq1P9m>?(K|WDRo2Q;RXUN(F_^PLbIPe*rgA%iY_j#XsXZedCCG!hA}7E5>`>2o zctfng!f!rf^xR+04<|-vN919o#{e@%x1uu9%{=T5=;$H=FER7-?bq zfB*AW*k{r)%3I9BZZ?hkF_|cW1@H}G-5GfP2a9Hf_fs1u5Dk>;+Nk%9sYr2EO!5%> zff&wQ5HnGHzb)gG(6*D!Va|?rm3~I@>C+84V4$&UaxRhD(MA0st?|?24EGPVu9)Nt zLdw@wl8I-{fzC}So^c#1^7f%5^Ta9o8PIZ|QQBqlPj(j*;sP&nL&{bb#L zHp|i#XqttI_J;3QlLhpsS@+8s)p!eWc@Na$H^S?2`Z~$feOiMN5?M|-CTj3czFrZ0 zc-9&;yt~4pJ4y@7$t*=4kmEZU)e)g=9=G49z{>WRyqC3@?2zc+K~0nJZZK(-DnJQ& zuCjB$9q}n`(WxRqgu3D5<8$+LQt0MSf?XlSy4!n=)JY~1`Z{@w=vB4}b6iI8ieA&^^XHBY36;oDK{ze~QNs*8j}xH*;aN2z>kZQ?kRUfu4iB8=V?nrx zIUFDmv&!k|g*@K`!^v*n{g<8e1_1>L`M-#6|J#Q62A_Zq2@fP?#-*W2D=BUMxq8dkuj_KqE6o~-nPo0W*IDV|1%B@~U#>)}KZlqZ#8icVF718x7 zb?`2KpA$GDgv#Uklpj$l1Sy1H|A`qPwS|Agx>{1ze@~`v4#@X5g)S=2dKC$aWQbQl zJ{>HZ!u$ydZ9M2!yhnRTEsjlx&aQRC*U3r!pMEz{uoTBD5ovxd0kafUQ-G|=htCQ=vdpG#gFACgQG>Pq8<|!y<>Rvs8FI18>jh)d zP|6ea^Zh4`jLF(rt2>6t9QwFeqMxS@_;F|hmSxpcH7eP^xY_H)>b$%e<`CO#D)aj0xUp;}UoyT_1 zLvy=gV&p8E`lM0uYi?t|*aZ7GAFGV`iEF>VEXSiaOwG(C9qT3uj)|Jzms_K$&=XD* zQ)d;5qsBXCKZ03`zZZX!(}euCwR|M;rXTadr$>UBpr28ZvoRLM*&5O9^IFJh8aGmK zv1mf1qj&XqD+b*_@U7?_d|31uA3SXo;Wn-%`N(_P9G`-p!agzQs5aqk#O=-2lLA|l z{zM34-$-b-RQx^5tkBeud+F`1kSeZL^FUZ~EBk_VYXU5$k-oER?sSJa+DpA$UGM?c~TUzgDVTSzPgpDAvY}_$V_ET1eeu^eSLVWoT5HO=R zk%~MOl+iwxr38O?h~mZ&7T%$;0A(a%`B8Fhp$Q$XadRw^{+|C4uE`p3+GZp~6eJ8} z6f8tk)HiS|9!}c~xA;Na$%J%LDD*sh(wgR}MR-JRJ3uXukYZY1Newd#SNF!gSq5S* z8Hi`7_UEt^*yWdg#-s+>|4!$ODgmc+e*8JeKA@9j--M%UUNy;Q5LwN&U`E2$k39dPTLW1P{G_UaI;!YpDW$QTpYb zjbe^27Odq?f$dC@A%Xjgy5LP-hrHD{@lD@Oq??D~0h3L+0b19vmn^BpD6Mu}Mp8nc z+PR(cU#n*E6OH(fkd2HdJ3nsLEzfpRy->WyX@_Z}sjoUuGUrgL1IH}mF+slFbTDh7 zXXF-m@uN4z&pOq3(#GkDz~=4_;92lUQ<}tY%rqIleje%H&N|)K4rw?ic1Tqc#h`@? z*D1G_&U|~w#o(;A8rEj@UjY{#{~r=E-Zy&m6I9OP_OUPHe=okeQYx)*b-)7kVu@x& zI?nrQH+8}+_H?H?d1LXyYTtGlqY=c#75rqK;@E7R6XlC(d~++b;?kPglgpg>O}(nm zkfRj0Vs8^)pg>jGIi$yl{k-1y987=Ivj+%m)|$Yo2Q3C)ow9e|X_NA`Ai6hY+0W4y zEs*`b>*=WDZ#L4W@r!LMP+vg$USG!v9Z(kR;dU? zjY_*TRPR_5`^d74SH!D_@OU~i8;}U%-7@*rtFENK#_A;filTXP=%qkUvlE9kFARpokTr930Jq ze7MqAlAx*%n``8St$_dBtk(ovY#qm^wDW}#Xb}4w`h4U#tgX2yk*LDwf1-AeYE4&# z`fR)>tqN!7d@E?`^ezsHD7IL{^MweXKZ*#z7US2KK+F=I=h8+Zs8Of!3MZDd8vlNS z_Lz43`vJ?t*hvz+xMUh`Vc7F)-y~b>O`2|Yh@;Uo$k0Z>!8Q>4mf(5 zNcpy*FFrLsIAZ15ODw?a!Rj;csVQ|Bp2=Qz>I>>AAN)2qqzJ@)bvI%HL^CAnSjV9k zvf$pc(tAk8?mVSu83B28mLN8ZqQob%Ttj%SB@TM;m19k=D7u0P<%E1GXEm>MKQ^Ib z_^d}ZhHV!4eZ68t)Fs<iUWACOne1J=ijSX_EMWO@4l?jvJ z`VWC+^Xq=!PXKuI?S{w_ePcVnVX*93yxZvg?qLir^}8wd;a9hsHU*jT%dFp6a*F#*whLR)=a|ac#=+C19ba z#6v}DWLEWcw6OtfGMyo>b!A+!w9!rT?HqF!z5sBe(Ry66SKX~i^;7V@T1-eCuAhKO zh)8n8<%*%m!1Vr-q3X(BkvWZlA#<}}u9tJS2#mO88X=Z(POq}^4O^amo#tF%5{(#?wwq2nbfqV!7~SUT{XF|2z{X~gHu#6c z{I0sSI^d`LP`eJl;1JoYBq?h!%j?7oS<~tFfD1iuz1p_|YJ-H5rLd;dGq=_=PO;o? zj8Yg)D!rq*Gai2B+cj=oB`Ic<@!ZXhc5;^C2Dg83BhvfyX&fvy6B;_ne5w(WKA14N z`wyE7Qcr8&3p}EXyDR=|xdUvIGAmO5qEpvE7x#l@=*G5EItIv1ykmqH9rHuVHn^Q1;i&9SvBHacGI~9*z}%d1{;)SCJ}j%Ujl=e2 zi$B;nDnt>{MK}K*TlHR@8)4M;_Jx%A4YF8-tTD@c6Y!y*uxfA~C)-%K(tz0o&&&0| zn%&f5mz?Ze5EkcxV`J&<^;dPIC}H9=wt&4{#7hm?6Qf%1wr+h4%P^e~h3`wuSbdhtjtue8ZKKLAPCQU^b3s{+V8(77pF2 zv1Z3ot;)UBGigC%2e-3YaZKr>Gp0=cP2Up20vf%{4{XK}J8M3h_pE1D+pS>Zu3}Gb zWg`Qm1lz;;2r=gGL?yniTRMz!wisULULx}vgj&NURUo0A1vf8g9EO%Dft;P*B4XGw z6~Z82^Lvdkw!Wr*q2&9!bgH^3+Jt)r);u=nVQr!s2W68x zfU+E?Pa-kp?UQ8ENOBDkqPpWZ^yEzE65^Ax78G@x=2eP2QO!D>K7*{W4ay%@zHmKI zHo?Mi1~pef{r~HF6r`?xsDI!W#isE^Occk`=@bO6?Ij=jCCt$L*Qu7%U6ETC;^Np@ zzH&QThk>~0opmDqiQRaowQp;xb@%idD68gY83@jqu{tsseC%dUnz8EbP%LxXHY79( zfjpi3TSb0u$@gtuK1_;Y4NfI|R)8tY3471bM*eVZViamXQ2G`3t8#M`#Vh|taWPUq;F|zR-udJh^IYpD{q=J{qWDG zZ#rM=?a}t;-VnfRN6G5yAA&o!39tW1tfv-N=erAMWTHa@hzI4_QcRygSR39A=`|bk zwZF;pj$sgR_2H{SeHYaU95H&wG{EPKrwN@#x8vIkc6q-FbQp=PEg)&Qdrftp1(8xxVT&r>8oepUO4jzqJi{bcKycUIwa@pdc(wplaXxf-Or3+YP{9q76)A?W7q z-f)RgD{xpsb9WfVC4TO}*6VH@ojwlhT?q-7_ms_zU<$y+$o*qRuBOuVmZZ}<9EI!i);PR1U zU7IPkjV2qlWlil;Z0tXTP99aG zunum!k8X)y-xb17nzSuR?=bzb^AvEx%nXW{oW9#$IBz}fV{FR>a%nZChFh>qVJGft zpJoiB8d9)L_4OHU!(g1nNpa`B$WLS;1^4UI^}NU@R6RAT8{d|A-qi9d^EwJ}d9<^f zt`{%AW-`S0SE)B>4UA32^8@7-UT-*{^JEj+Mt*h9D28Is#s%0@RCT}U7%TYdf-Yx! z*{}>3^J|JCt7dGPh8b4d-V!5moJ9}u)P>Q!$+c)ib#mT%AA~3l_@E-X+N!%|3#)C)eiL=GU&N&g_N9MXowfi;4jj-H~%sjTq*y>64FSf{tD(Zaxm%d6rHq`X}|&Y)-_S zrbwUedeZ0-yPH~MC*%syl#YI$NUG$lE}LNGdq!F;Samh6ILS>vmawk@1ziCRL*nMj z{UV!jTNN9%KLS81*;k+p>8+>OfFwCeP&|Nz@0}kU*@WX^EUJ_HpvMp_@*Q^Pk4>xb zVS@Isl3jPP$$oJQ*BY5kHD!vB(jW|qw3`sSIqc-T>5v_wW$+->_>=uw0<)SgWe$Mo zx4ARu%gXNQJSi7E657zaYMhljv2n^3BBjk9=CfYfxMnz2Z4@Ash4(co)o>!&*)#D% zL;noa_;opXWINw-{}xW}RPTRj@06N`#3`ziXpKUuk>iT77%4}jnXoIccSkJ{jU|EQ*BB?XN_n4~@Am8@OVf)C+JG=ue`_`AMvK*U-wE%@$3nhB$+J|^6Y)x5 z24pmLzID$OL^5fzYLPOYiZqthZ&%VCV5=-N_| z2(7r!V%7Cj+bK^oCAj!$DT}&xa&MeEo3+*;WJJN2MkkAt90%V+lt&UnGwm9>e4-Zg z_E#KpaUkV)nuZ6q-8&t%7pB+u+n|I)x@gSa<25Of&vXO6>N)RBiH6+GFS5VtpPx2S zB~A%!zCZKb=##VhFCCTFyiy75Y3Q8ZlRos;NpIx7&*(~Px{ia?o7b3L-fwpb4Zr6F z`@%Y7Ql%(WV=wpxtmzD`-kgLf8*lE`!0`Q+C@40s6UuD2K{)3L?VoFIbGf=1fwCMV zS;cpsE*#V@d#;jY*Tfy7_=vpQ*L=s6zucuQO;GYQ=bt6wpSJif%rfTh3+InR1aa86 z?pV*-3<~0rU3GnM3Wk>6CLB22P31p2w-=>M5vj%s4j);2y@9&28q52foprLwgqg6u zH5b)@ao44!A8r#DkFbSVLuC?)gOGG(s3>?MARRYF_KDQlDN9d3a!t# z9Y0%75CrR#pej?zU#iu6gLTZBj1rc_J~u9FU+7v7IC`EX?{tyo6xy)9RL^6=Cm|Xd zPvC03<-+3*zGMlT))MSO9j@FDsfLt0$`^GPq5DVk#%sI_O8PRNIYhp(9@!6kdWT7= ziubDf58+zHUryNqwb1>pE3o5EjosnArD~NdsIw{f-G1Vc&#i78+u8f3Arc(x-M@zh z2j4dG-6ViQYAF(Cc-)?mMTBcGF0_p1?mYa4o=T8pnrPIpSMQQRUt`cO){;lJsiW0R9^sTC!% zU7Bg%zr^GHto=QG>p3lWbVy=_=^w&eu+`Xgh$dm!#$^?MLbWn$?`{YGf;_+27q@z3 z{S}HM(O3jM#=vSl#-8B>T<`0upXismdgHbm3Gad!{kxqV4%2)2zAU)&sADF|jv;i@ zCy5R83D35eLob5F(YH1Nmqz-yovM+Ld@lOj^Zu4FdF0K&@9dvCd9RtS1pMsV6L>~Z zc<^OgCKEqkO|05^Ye@BuRea!yjnexSs=9@|a_W62)UrW1_4^r*(GVCN6wo_+)u)9& zRWQ@Iu_?e2HIIWi>HB8L9@H*qvR$)fw)M(dI2r6>XB#aZ9VaT4K4jV~O?iIfjy?cb zCxXUgszmwoso`q>@Tymjd8gb{GiLH6(8-}slvwJkBb#{R)woAJ! zSS~{;)!4-+SVYNzW7f-jYT*7*S)4G(eHFjy5~7kn`Kasc(3Q$R+2aZ?%WTrWEhh@x zWP-J)2l-MQ-PQYc{GIRe?;}z8aY!kphSSuR$D$Ea3*cgvYbLQ?nsq#i7t0?Ko}>ey zS*+Hku3~$B{n?squu9D(I4j^dv4pd8r50ABt8ZJurToX z#Nzj_)ld@NvUde+`o!olTix!-9&f%I!NiNmpXKf6viApATT!(aS$opL3vizYKBZxv zk&Tl{hks+dBUfjb{9AB+VIY&B9HwPl|5~{)Ty)Y!gc9*b6EE`bpAx5%V5W8d5=Bfq6BMAt9MYhOsjTO|8$4Ut1vGSvH{jk^OG%WOMJkgZJNRw#ad?=UVRN&5#% zDY?_8*6NLLi203P*EivQ0TVEDYh$t!$7h#WlP;e=bp0PdKwa&ZCjM*CWbYw0icskE zst7UlY-X!cBfa2I^eW`~U9@N}iAbgQdyU2q`N%~9UP zeZ8VGF?p`HW#hDO&JLhw^+L~?ZF+M|jb+O|Rb`u5@O3N1;1b9yu8&6dQyuA>PCRO->i(F`RyuKvR zP4N$bEbm0^=K_ex5y$DIYKhYI8~TJytMhhQ>l&b$@t_v>G{UKXlq!F%|2;z4s*}^2 zD*v@{ZDS19N}SZ`g>G;o&8(qB`tKS0B+?nBP&FN~Tjg<*C=p)=7n4ilG8ttf43C@< z*Nsr8vc*~6ITyz9SsTlUzXH27dVQeDEzy4nGIv*+(jvh7A^Q|I&Te^;`VWF{@SA4{ zm6@EVyjF-de<3s*lnR8&R2bIc9^g7KlFHI9hWIyoyV-?Xx_S|pi8vN2@*{=eH9j@z z%Q!pLJD2yVS_OFK_su&=hq9tjFa8LB67>d8a(++@zc)2bL^|$S72#?g zd<)u{SL0vWOg?N2st?Wnyf|g!{R8)im@7SjL@5veC!0%{PJjv1`>PHKH7-oMRTrG9 z!_(7{$17}n+46=ThLR-0s0VDCdY@FLCKtW6m-X+$M_CNT#QCGKONd`S4|F3I+-y8)Zz-HsJ*M)7OTyg`#Tj^gO z4jl$m4sBAp3Ro!PgwG$nzP4r;`w<;7NxT%o@JJ#^Tz%*5jcqd;f%Tvd z@V_1H%oi)LW54bK*$~}D2`hfgk>AxR)@@G(RZ@*5GR}@q!g%m<#ftqn2n0A@X@*`4 zmX3($R-bn}{XgdEsXP3M{dE@PW+GNU%~sy>FywjrU9j%|#wnlA{~)qhp|9c%UW zu6b##hY-0Y>1~z6DQ9wDEm=za0MK;vHs^IIa*Ee2HR4F?d*=6>wmCQx^aZg#qh;8g zdYj*K?dGQ2);ck|M-a=Xdks}Fr+J^*88OS5SWB|th{?ur`!-=!P*mjpMwGDpcB}6H z#@>4eHT8XOqo4u`2q;xT(GMscq=SHn3J6G(-qFx|4-lFlRYUJhihz_*LkS@u(t9rn zN$4OgG)YhdZ@%B(+;{FD@7z1@%zfwn=FWE}li6n{bF$7pJNvA?*7H28v@0s@Gvpe2 zIj2bYZt{tgtY^BM^^Q>D*tndFwlVljV<* zW~uphV6^GP#o^_%g6l^a@4MM@KG0StXF6H|H7bpW@&U@$)yra+6hekaZqqXV2!vJ5 zTD4K#I0Q9mNV)f|`@lwSVL^d%AC&X278X0XvFc1ItW=(&R5$MN(kDvzrj`KLMCq5! zSE4VZpmBt~%XEo20yK;INu2p>`?(64s_}b(9N2Hog{hk@Iv#yR)=G8rX|KhdFa>U{ z@Ix_$XhWGiKtZGT_s)bdu3m)4 zZz5-@B~#|$doNoYj+FJVZ##9xKqX|xS4rwxNN^=&0{yggFXqq{`{+t=9n&w*(s1!i zuI|fiUD_e!-T4TETTT`jW>>hD_zzTM)>?iDLjc|vzSPEtfsC&QpWTt=vQmjZRIT}q z$Q*f#K(Y)lZ_Oz?EqzHJS%c6Oz5a(4R$tB;)nl<652lgRt6Wd;q55e_iI=i;<5v(9WK9b>n0(?+3_X2dg_6&vE zm;K{>WpIol!L}QjMNn?=W#(a!IHuaJ+qmM%RJ|EM>Oj!9nIsgk!H8$KQH8AC)fjy1IZj(3d4;;mAXFjEh zJ2VD!Q2xoPa)L`KP>Ppy1vfU=-k2>INxk|@_Lt0XmwoefrTbF(@6C@$PAsm-b0mfQNvF>$NCj=@TD>R1QPF&$^80@JMgJv z9VsXi$QIrOO2|C--=*!kp^I01+HxhX8lw(y)k2JoK6 zZ*IR=Mqo<+lBr!ea1C?r`G^IOhf3DIloQFPq9eIHp$!@1uCCTD_VIlS08NDi8HR1Q z2`;%hzhz#Vj&`%!Q1d!}6a*sR&j?Fz+MRnfh}n)^4`Sp?xB(EZns>Ujt59kATxc`<+O5k%AS*|L#^YC4IB%9`#prJZr(TqLVhB522nOMpS z)i805)G^j=SDxlt{JGnkx#7>ZDpS&P}p+MVt`BDkJ&U1&WRczPR~6n%Mnz zE;P{urLI(To`FF5rC1-@E9LzqGk*O;2;AXCFa9z3*bkwz2k|on+s6o_?uBxfY z<(9g(29FH+S+q94mCWfo@K{Og z?QBeFIOt*O$0o~vViseopp*zQ$5{6p-Vk|rx^igSO{0Z19XIzjc4%mNjH6d*1BsNf z5fWw!1%78MF#?V!@i+vGUI6jOk|FiwFcl8NYs`9Ff*l%8Mr|h`Pt-{?1AlL zwCfmop=y4yn8A@Wk+IC|yp#oQ&d1Om3ep&5LYv@3UVG4^7N8c5h57aDYTo>U(VS^Y z4Ao=86TGAEbGy)PEd#EB9;S&XL+|HeI;(9U9)n4Y>TyiuafhV81P@Re^2-ZF0- zorC?Ei0r5~_$;4Fd73@*QLp(VddEI1Opbz&FN^g#;ozS-3W{7RtJKMh<|B1rP}BQ% z&BH1`{Ia4W2|4HUm+aTQ#R~&s8+>Qf@4M-tu`>C7uE%Ide_atAOq?syQ3=ISsXIqtIpq2fUhNRH}R_?CyrKX^TxyHoL8=_)qJ=QR}=VZL*@sHWoB-_ zrvZ~J0E8$cryvTV@-RkUn%Kkr9q+vN>VJA>pD>|f@3(eIosvr4*k+>K8MSJ7-lK{o zX%eipUfPggQKqNhwyT6Y4lJA**trhq>t;5~fwtVoc+TBNn%jA7-bB~MyEQf*%d;1z z{dmDs2H$X7B{VoGkQvb{s+T+96rwhMI=1qB!_}^2&w84^@2PsObV=DY{LGML8BNoH zXXr3M0W`#0^25NiuAmI()#GE|K1^hlo)t+8)f1Gv5cf@Lv_bqX4`I)70e)Ow;<~96 z>unIC++OXCqF~1GLk~Pu%=tMbh3uViRA<_#=^ZcC^d`$Pxvz^Sx9HdeEVR3;Cj7rC z!Tzc2ytwXRA_*qZ_E~kdS`=?@z9YKywFFhZwX7JL9%>OEFjl@068EH>QuV$0b~~7) zX*uMpz+-p6fU$5N%4{5OVMTxxoqN2dd^8l)EGn1b-gG^f-@cR@d=cp+pCor7Vl&L# zP`2?{feexvmXghNyx$yh&HrjdzKWH0buB?8u6oq(jrdw-M9`Js?2i_st{0&)X0PR) z)x3=tygR>Cum~vOHX7P+#eGjx_A&G%jkK?{2FCd}dquK0W!zdmeR*iOW6^NFuPy^3#=RPLw-9F%3U@>0CxO*eAOR1qBD##hdrW_GNOyObocE-?T}059fL{|pcI(uq{JNs8HLnZPBEwBvs+vhTg` z3J&ovTh;uU1M~6=vSbWh10QfEqr*!43}k8q`v={9+7>D|w={UiiSIT}hX@1D z268j4stTYP{XtEyy{)&M(?|NRDBK)2?{PZ(ZUrSHF|t1@13ub2Zn8GgSDC$ede-=H zO=GP%z6c{)oz$Xn=*U#YbYfjQJD=VyJ?1|Ii2B;P_Gxmg$+rP(xDcDEiQ!zrv~Lk`aEAq~0;Int z7+2|=@3IznHOhjx_ECoh8Oy{m({?pxxKbxRcWcbG$|9-N5&Rj`a7AU1bi;iAdHdC; z--4f%Z7w<}3pQn)F7^d0>w9uAddVY!-WH}yCd@F2%)*V;M?49TC?Ri)o1A`h{4yrE zw*;A+l1vLZVzO}eSdKe+P9fL4!U8Jv`(%j;#Y08Cs?^?qHH>BIM$+@J#Os5a2_ zzWeaOz(@8z^v>O0Q-<5iow42bRJNjn`Gex{r7=$@=RZz!-30eaIa?THu(pVmm@E-5 z9%VAdtWeXr&v+dP?w}^Qd_2b0q$`lpsmO8ytG-j_QwM?Zlmo9--Pybx8>C-Yb?KQE zch!$e6ZPG8WuvLzwMldTUw2krn5idmJB(c{b$dAe_cHB&GFlq?UBUJi+PmVQ*kJp^ z8!pR+IFG}GN3!|_b2^ZQ(-?)>Y3v*Pwx|9aOnb}g@L4i^to-VX?qE)T3Q>tG6Ja() zOvq7}DnhE05`6R5ZmkB2lBlluTP2%L;#Fy&U{{pdOy8}Si5J7bTHsnsujixe@*J6b zJXziO;NS?J?suO&(WALTVNR6%K~J~ZAFZG-p#L{+V&%_7{MAo>+^z3y=ksN3WuDVQ zy~Y)hsL}oop_0c!5mh1SxU;yQ+DOgN5hq)Uo<8X&NxM15XQfXCO1AcpyT&oI|12&& z4>8OGz+2~(M*TweL3O2VEpNvw!vAk{UL*d*l-?^`<#xI(xZRmybr)QqlsB)#EpDxh7L5A7=XGH6k z(&`hC%wA|>AoGKJ4!}ZiS~ibxk=6YsP*8;r`M2qO0krVCe(G%`!zi6@j5)k%?`T z>W4+2@pY8SK2n?HiC1`X<3aQeTnqKad&h>TsADxXYVNndBWORh{>$78q-D6YOv-bw$~4c+(qm!wykg|g;G)L@>>QrZ2rti!$2N5l*FFo& zrKHhjb-Vj6bC}-!CBJqOFCl-b=TCel4U0z2<3X$+uZg!8kd%S_J3d46A zJ-ok{#OHyqogUGRj2{;RT}l)0X+7$=g+-~ovN68hyHe{u;O~qu;+iAAjDaXJgdDa{ zJ0BaHE(y4@ho$S^dMEw1=gdg%NVT?Cu$B2mei%IR`y)Sbcx%2z{E_j3sbfl7j|jkk z6mqa@R5M?)yvW}=I#jcGSRNi^i+r;`ET0G@eI}(~(l|u9?h7-dPc-8MrYvwRdM3%`DM^OCny>w;Q?u44vK4Ks9sS6C8OQkNzaQ}t4fY3f8>uL zg=!%rBgAPJ(8V-qV+XuK8~JH`y;PMN z$pm}|DLa|3|`}!pdOoSDZ9N3acu-abOO}BsO&vS*07nJsy zCNh?y#)Ui|S}+Wb|Fqn{meQ!qddh%)^6^K34TUp@=!bW7)dLj%hs&5cwUy^c81~4d z)Ze@|hk1+SX$$`wYv&%A!IR^f7IvA}_;qB>7T$g!OU-8*q#USFmKXJWkyNIgt7>96 zEmU+@sY}1I@ukavtpN|^o?Wu^KsP`hQu0LK$0{})fm`z~`AkZQx^Ly$!i8Lyz$$_k zDb0u{Ruz4w*1oKg;h6k?SUu&WrodenTuKE?NJ$ffPcp&}XpVhN91^=gghh8*^!Zq? zBKxZ%49={lat0bt>CP_J@BCho@kl{*JC{bbjsRgs^{+g|*IKi9&~fc3cJ*%+@=Lkc zO~d1$2R`YJGNfG?P@#?NKEIbO*w6b# zO_Ik9H@aPax`V>I(7(kPE)VMYs>?}K)|FXaZdAW^8-Xy`FWkI-mC~(%CCYj+Jy-T| zc7NK2D;$cxsf@L1m*8LQK~gTcWm%lX&;)RQwR}|812Kv--Aa`p<{HjQuD~^WTuUnw zp2W(+m@^M&OCk^7ae=B}q%zXRZ+Tus%l*->Kk<~`_T5ZXBncj+k{z_P3)1z+Rh>sp0dNeG`V7LPgaDlzwL zO=}HN(UOA0kB)$T^Mzp1_FJNYSOAC`e`F!=wxmWkV-Nty`5o)9ZRLeq05LbnFp6ce*EdNaOF5BRVv;deZ(x2-#p?N=`X&Tt{B?S0`S~W8uU@M@)qR_aDpM-57 zk;!cw*4^;WPHM~s$VK+TkSmGESyE_I0g1BwL%v+c1XKl@waBY{s`>R>!$ks zQ7b^KJs4R&#}Gv&AC>>SVZb(7^@e)kv@gYBNS+Wd8y^@zr;iTOF;*N%`Sgu{yGf%F zHjsgNN+I1*DrG_DV#eyOXIqi;ZPFL8pAyANEe5LgoZ(_C7hn2;X1e&`#@Q36>p0J3 zA#VjJxfYpSvLv*QDx_b@x7<5X1}8io@t=b_eTjU|d20m6GL|~v8I zRgOB!UAWVEGo{Qi>ADU|`yUK7p$6#=t`95{UlQDKyUF^w!&|Wmk~$Q(pgMGc!+;2b z*UtW{_x|B=_%kMe`$2HSxcdKLO3Wc{guS;66?y{_()VrE1Q|r4T3+Sj=;VsNxvpQR zJg<`Aw%;n+`b!4Tvizuv-LJDF?Vh|YGjXk9IRROpF;Nbm8mlhGHw|cNLC_+_Szzso z5IC~)5KeQ{CX1w!y?)R(zZ?wbk?}Aoj6Szy53C;hR zj{CW#*3WgY6ZyUt@PiuZoqkwFmriy_KWk>$sAL!Rk>{rwcgf%-i~5g7W!045!bwyA z%{qs(IIV9sFD5zSnT?x!ng1J^h(hI<1VRgn(?OAg8$9t{8fy{F5)sGL7rDDUJnsO? zkHdb3Z6 z{}|IKZ|4J@2_(T&dPPN`3;Nukq})4n&Yj}qcEQ)4Oh5kgaO~>}!w31+h;AzJ1$y>q zlhNsc;PU>VXV^$D@F5KN>|bXX+)Nqa$y66kG~@VxKJ+i@Jvl06YNHB{&inCEl-=r0 zz08S7o7nB2D>*O-2De%78ngqA|1h-HuKe-<7J>!|aIx74bkhe%P0pE$3!-c=nwv&X%!il0&?t&`2mzPy!Pg)4>=fIGQbh zGLTw{zkU0h&)klwmRzpU+Vl2a*_k#v;*}1t6wc|(=ccX3Rn;l*?Je@@mWzi$L*j4E zcg8cG%x9r#sxY6LKL_Y?TtqC#HF9VNH@vq(wW*D;{@d!I=TrzI;JWrEV}mdAH@e9L zK?E!N1)0;f{Jn=AJJelv9EZ#iKh<$$h?fVY+Dz|Q3NVJgF#A5sPXN5OYr~sAOolIZ z#=>*7J3BVETPV4m&hi+&}#YQv3`IW$$D>rOix)6Oqv%B zboR5q#W6ik_j57o>8Io8Qvesa^5z;5r$d#RzHE_{4>&;j^bow)Hh�FipuSKBx$5 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 z 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 _}po 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 diff --git a/web/classic/public/robots.txt b/web/classic/public/robots.txt deleted file mode 100644 index e9e57dc4d41b..000000000000 --- a/web/classic/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * -Disallow: diff --git a/web/classic/rsbuild.config.ts b/web/classic/rsbuild.config.ts deleted file mode 100644 index 3ccf1e96df8e..000000000000 --- a/web/classic/rsbuild.config.ts +++ /dev/null @@ -1,106 +0,0 @@ -import path from 'path' -import { createRequire } from 'module' -import { fileURLToPath } from 'url' -import { defineConfig, loadEnv } from '@rsbuild/core' -import { pluginReact } from '@rsbuild/plugin-react' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const require = createRequire(import.meta.url) -const semiUiDir = path.resolve( - path.dirname(require.resolve('@douyinfe/semi-ui')), - '../..', -) - -export default defineConfig(({ envMode }) => { - const env = loadEnv({ mode: envMode, prefixes: ['VITE_'] }) - const clientServerUrl = - process.env.VITE_REACT_APP_SERVER_URL || - env.rawPublicVars.VITE_REACT_APP_SERVER_URL || - '' - const proxyServerUrl = - clientServerUrl || - 'http://localhost:3000' - const isProd = envMode === 'production' - const devProxy = Object.fromEntries( - (['/api', '/mj', '/pg'] as const).map((key) => [ - key, - { target: proxyServerUrl, changeOrigin: true }, - ]), - ) as Record - - return { - plugins: [pluginReact()], - source: { - entry: { - index: './src/index.jsx', - }, - define: { - 'import.meta.env.VITE_REACT_APP_SERVER_URL': JSON.stringify( - clientServerUrl, - ), - }, - }, - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - '@douyinfe/semi-ui/dist/css/semi.css': path.resolve( - semiUiDir, - 'dist/css/semi.css', - ), - }, - }, - html: { - template: './index.html', - }, - server: { - host: '0.0.0.0', - strictPort: false, - proxy: devProxy, - }, - output: { - minify: isProd, - target: 'web', - distPath: { - root: 'dist', - }, - }, - performance: { - removeConsole: isProd ? ['log'] : false, - buildCache: { - cacheDigest: [process.env.VITE_REACT_APP_VERSION], - }, - }, - tools: { - rspack: { - module: { - rules: [ - { - test: /src[\\/].*\.js$/, - type: 'javascript/auto', - use: [ - { - loader: 'builtin:swc-loader', - options: { - jsc: { - parser: { - syntax: 'ecmascript', - jsx: true, - }, - transform: { - react: { - runtime: 'automatic', - development: !isProd, - refresh: !isProd, - }, - }, - }, - }, - }, - ], - }, - ], - }, - }, - }, - } -}) diff --git a/web/classic/src/App.jsx b/web/classic/src/App.jsx deleted file mode 100644 index 0dccb50539c7..000000000000 --- a/web/classic/src/App.jsx +++ /dev/null @@ -1,386 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React, { lazy, Suspense, useContext, useMemo } from 'react'; -import { Route, Routes, useLocation, useParams } from 'react-router-dom'; -import Loading from './components/common/ui/Loading'; -import User from './pages/User'; -import { AuthRedirect, PrivateRoute, AdminRoute } from './helpers'; -import RegisterForm from './components/auth/RegisterForm'; -import LoginForm from './components/auth/LoginForm'; -import NotFound from './pages/NotFound'; -import Forbidden from './pages/Forbidden'; -import Setting from './pages/Setting'; -import { StatusContext } from './context/Status'; - -import PasswordResetForm from './components/auth/PasswordResetForm'; -import PasswordResetConfirm from './components/auth/PasswordResetConfirm'; -import Channel from './pages/Channel'; -import Token from './pages/Token'; -import Redemption from './pages/Redemption'; -import TopUp from './pages/TopUp'; -import Log from './pages/Log'; -import Chat from './pages/Chat'; -import Chat2Link from './pages/Chat2Link'; -import MjProxy from './pages/Midjourney'; -import Pricing from './pages/Pricing'; -import Task from './pages/Task'; -import ModelPage from './pages/Model'; -import ModelDeploymentPage from './pages/ModelDeployment'; -import Playground from './pages/Playground'; -import Subscription from './pages/Subscription'; -import OAuth2Callback from './components/auth/OAuth2Callback'; -import PersonalSetting from './components/settings/PersonalSetting'; -import Setup from './pages/Setup'; -import SetupCheck from './components/layout/SetupCheck'; - -const Home = lazy(() => import('./pages/Home')); -const Dashboard = lazy(() => import('./pages/Dashboard')); -const About = lazy(() => import('./pages/About')); -const UserAgreement = lazy(() => import('./pages/UserAgreement')); -const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy')); - -function DynamicOAuth2Callback() { - const { provider } = useParams(); - return ; -} - -function App() { - const location = useLocation(); - const [statusState] = useContext(StatusContext); - - // 获取模型广场权限配置 - const pricingRequireAuth = useMemo(() => { - const headerNavModulesConfig = statusState?.status?.HeaderNavModules; - if (headerNavModulesConfig) { - try { - const modules = JSON.parse(headerNavModulesConfig); - - // 处理向后兼容性:如果pricing是boolean,默认不需要登录 - if (typeof modules.pricing === 'boolean') { - return false; // 默认不需要登录鉴权 - } - - // 如果是对象格式,使用requireAuth配置 - return modules.pricing?.requireAuth === true; - } catch (error) { - console.error('解析顶栏模块配置失败:', error); - return false; // 默认不需要登录 - } - } - return false; // 默认不需要登录 - }, [statusState?.status?.HeaderNavModules]); - - return ( - - - } key={location.pathname}> - - - } - /> - } key={location.pathname}> - - - } - /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } key={location.pathname}> - - - } - /> - } key={location.pathname}> - - - - - } - /> - } key={location.pathname}> - - - - - } - /> - } key={location.pathname}> - - - } - /> - } key={location.pathname}> - - - } - /> - } key={location.pathname}> - - - } - /> - }> - - - } - /> - } key={location.pathname}> - - - } - /> - } key={location.pathname}> - - - } - /> - - } key={location.pathname}> - - - - } - /> - - } key={location.pathname}> - - - - } - /> - - } key={location.pathname}> - - - - } - /> - - - - } - /> - - } key={location.pathname}> - - - - } - /> - - } key={location.pathname}> - - - - } - /> - - } key={location.pathname}> - - - - } - /> - - } - key={location.pathname} - > - - - - ) : ( - } key={location.pathname}> - - - ) - } - /> - } key={location.pathname}> - - - } - /> - } key={location.pathname}> - - - } - /> - } key={location.pathname}> - - - } - /> - } key={location.pathname}> - - - } - /> - {/* 方便使用chat2link直接跳转聊天... */} - - } key={location.pathname}> - - - - } - /> - } /> - - - ); -} - -export default App; diff --git a/web/classic/src/components/auth/LoginForm.jsx b/web/classic/src/components/auth/LoginForm.jsx deleted file mode 100644 index 63305e8329bd..000000000000 --- a/web/classic/src/components/auth/LoginForm.jsx +++ /dev/null @@ -1,983 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { UserContext } from '../../context/User'; -import { StatusContext } from '../../context/Status'; -import { - API, - getLogo, - showError, - showInfo, - showSuccess, - updateAPI, - getSystemName, - getOAuthProviderIcon, - setUserData, - onGitHubOAuthClicked, - onDiscordOAuthClicked, - onOIDCClicked, - onLinuxDOOAuthClicked, - onCustomOAuthClicked, - prepareCredentialRequestOptions, - buildAssertionResult, - isPasskeySupported, -} from '../../helpers'; -import Turnstile from 'react-turnstile'; -import { - Button, - Card, - Checkbox, - Divider, - Form, - Icon, - Modal, -} from '@douyinfe/semi-ui'; -import Title from '@douyinfe/semi-ui/lib/es/typography/title'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; -import TelegramLoginButton from 'react-telegram-login'; - -import { - IconGithubLogo, - IconMail, - IconLock, - IconKey, -} from '@douyinfe/semi-icons'; -import OIDCIcon from '../common/logo/OIDCIcon'; -import WeChatIcon from '../common/logo/WeChatIcon'; -import LinuxDoIcon from '../common/logo/LinuxDoIcon'; -import TwoFAVerification from './TwoFAVerification'; -import { useTranslation } from 'react-i18next'; -import { SiDiscord } from 'react-icons/si'; - -const LoginForm = () => { - let navigate = useNavigate(); - const { t } = useTranslation(); - const githubButtonTextKeyByState = { - idle: '使用 GitHub 继续', - redirecting: '正在跳转 GitHub...', - timeout: '请求超时,请刷新页面后重新发起 GitHub 登录', - }; - const [inputs, setInputs] = useState({ - username: '', - password: '', - wechat_verification_code: '', - }); - const { username, password } = inputs; - const [searchParams, setSearchParams] = useSearchParams(); - const [submitted, setSubmitted] = useState(false); - const [userState, userDispatch] = useContext(UserContext); - const [statusState] = useContext(StatusContext); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); - const [showEmailLogin, setShowEmailLogin] = useState(false); - const [wechatLoading, setWechatLoading] = useState(false); - const [githubLoading, setGithubLoading] = useState(false); - const [discordLoading, setDiscordLoading] = useState(false); - const [oidcLoading, setOidcLoading] = useState(false); - const [linuxdoLoading, setLinuxdoLoading] = useState(false); - const [emailLoginLoading, setEmailLoginLoading] = useState(false); - const [loginLoading, setLoginLoading] = useState(false); - const [resetPasswordLoading, setResetPasswordLoading] = useState(false); - const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = - useState(false); - const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); - const [showTwoFA, setShowTwoFA] = useState(false); - const [passkeySupported, setPasskeySupported] = useState(false); - const [passkeyLoading, setPasskeyLoading] = useState(false); - const [agreedToTerms, setAgreedToTerms] = useState(false); - const [hasUserAgreement, setHasUserAgreement] = useState(false); - const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false); - const [githubButtonState, setGithubButtonState] = useState('idle'); - const [githubButtonDisabled, setGithubButtonDisabled] = useState(false); - const githubTimeoutRef = useRef(null); - const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]); - const [customOAuthLoading, setCustomOAuthLoading] = useState({}); - - const logo = getLogo(); - const systemName = getSystemName(); - - let affCode = new URLSearchParams(window.location.search).get('aff'); - if (affCode) { - localStorage.setItem('aff', affCode); - } - - const status = useMemo(() => { - if (statusState?.status) return statusState.status; - const savedStatus = localStorage.getItem('status'); - if (!savedStatus) return {}; - try { - return JSON.parse(savedStatus) || {}; - } catch (err) { - return {}; - } - }, [statusState?.status]); - const hasCustomOAuthProviders = - (status.custom_oauth_providers || []).length > 0; - const hasOAuthLoginOptions = Boolean( - status.github_oauth || - status.discord_oauth || - status.oidc_enabled || - status.wechat_login || - status.linuxdo_oauth || - status.telegram_oauth || - hasCustomOAuthProviders, - ); - - useEffect(() => { - if (status?.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } - - // 从 status 获取用户协议和隐私政策的启用状态 - setHasUserAgreement(status?.user_agreement_enabled || false); - setHasPrivacyPolicy(status?.privacy_policy_enabled || false); - }, [status]); - - useEffect(() => { - isPasskeySupported() - .then(setPasskeySupported) - .catch(() => setPasskeySupported(false)); - - return () => { - if (githubTimeoutRef.current) { - clearTimeout(githubTimeoutRef.current); - } - }; - }, []); - - useEffect(() => { - if (searchParams.get('expired')) { - showError(t('未登录或登录已过期,请重新登录')); - } - }, []); - - const onWeChatLoginClicked = () => { - if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { - showInfo(t('请先阅读并同意用户协议和隐私政策')); - return; - } - setWechatLoading(true); - setShowWeChatLoginModal(true); - setWechatLoading(false); - }; - - const onSubmitWeChatVerificationCode = async () => { - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setWechatCodeSubmitLoading(true); - try { - const res = await API.get( - `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, - ); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - setUserData(data); - updateAPI(); - navigate('/'); - showSuccess('登录成功!'); - setShowWeChatLoginModal(false); - } else { - showError(message); - } - } catch (error) { - showError('登录失败,请重试'); - } finally { - setWechatCodeSubmitLoading(false); - } - }; - - function handleChange(name, value) { - setInputs((inputs) => ({ ...inputs, [name]: value })); - } - - async function handleSubmit(e) { - if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { - showInfo(t('请先阅读并同意用户协议和隐私政策')); - return; - } - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setSubmitted(true); - setLoginLoading(true); - try { - if (username && password) { - const res = await API.post( - `/api/user/login?turnstile=${turnstileToken}`, - { - username, - password, - }, - ); - const { success, message, data } = res.data; - if (success) { - // 检查是否需要2FA验证 - if (data && data.require_2fa) { - setShowTwoFA(true); - setLoginLoading(false); - return; - } - - userDispatch({ type: 'login', payload: data }); - setUserData(data); - updateAPI(); - showSuccess('登录成功!'); - if (username === 'root' && password === '123456') { - Modal.error({ - title: '您正在使用默认密码!', - content: '请立刻修改默认密码!', - centered: true, - }); - } - navigate('/console'); - } else { - showError(message); - } - } else { - showError('请输入用户名和密码!'); - } - } catch (error) { - showError('登录失败,请重试'); - } finally { - setLoginLoading(false); - } - } - - // 添加Telegram登录处理函数 - const onTelegramLoginClicked = async (response) => { - if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { - showInfo(t('请先阅读并同意用户协议和隐私政策')); - return; - } - const fields = [ - 'id', - 'first_name', - 'last_name', - 'username', - 'photo_url', - 'auth_date', - 'hash', - 'lang', - ]; - const params = {}; - fields.forEach((field) => { - if (response[field]) { - params[field] = response[field]; - } - }); - try { - const res = await API.get(`/api/oauth/telegram/login`, { params }); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - showSuccess('登录成功!'); - setUserData(data); - updateAPI(); - navigate('/'); - } else { - showError(message); - } - } catch (error) { - showError('登录失败,请重试'); - } - }; - - // 包装的GitHub登录点击处理 - const handleGitHubClick = () => { - if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { - showInfo(t('请先阅读并同意用户协议和隐私政策')); - return; - } - if (githubButtonDisabled) { - return; - } - setGithubLoading(true); - setGithubButtonDisabled(true); - setGithubButtonState('redirecting'); - if (githubTimeoutRef.current) { - clearTimeout(githubTimeoutRef.current); - } - githubTimeoutRef.current = setTimeout(() => { - setGithubLoading(false); - setGithubButtonState('timeout'); - setGithubButtonDisabled(true); - }, 20000); - try { - onGitHubOAuthClicked(status.github_client_id, { shouldLogout: true }); - } finally { - // 由于重定向,这里不会执行到,但为了完整性添加 - setTimeout(() => setGithubLoading(false), 3000); - } - }; - - // 包装的Discord登录点击处理 - const handleDiscordClick = () => { - if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { - showInfo(t('请先阅读并同意用户协议和隐私政策')); - return; - } - setDiscordLoading(true); - try { - onDiscordOAuthClicked(status.discord_client_id, { shouldLogout: true }); - } finally { - // 由于重定向,这里不会执行到,但为了完整性添加 - setTimeout(() => setDiscordLoading(false), 3000); - } - }; - - // 包装的OIDC登录点击处理 - const handleOIDCClick = () => { - if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { - showInfo(t('请先阅读并同意用户协议和隐私政策')); - return; - } - setOidcLoading(true); - try { - onOIDCClicked( - status.oidc_authorization_endpoint, - status.oidc_client_id, - false, - { shouldLogout: true }, - ); - } finally { - // 由于重定向,这里不会执行到,但为了完整性添加 - setTimeout(() => setOidcLoading(false), 3000); - } - }; - - // 包装的LinuxDO登录点击处理 - const handleLinuxDOClick = () => { - if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { - showInfo(t('请先阅读并同意用户协议和隐私政策')); - return; - } - setLinuxdoLoading(true); - try { - onLinuxDOOAuthClicked(status.linuxdo_client_id, { shouldLogout: true }); - } finally { - // 由于重定向,这里不会执行到,但为了完整性添加 - setTimeout(() => setLinuxdoLoading(false), 3000); - } - }; - - // 包装的自定义OAuth登录点击处理 - const handleCustomOAuthClick = (provider) => { - if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { - showInfo(t('请先阅读并同意用户协议和隐私政策')); - return; - } - setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true })); - try { - onCustomOAuthClicked(provider, { shouldLogout: true }); - } finally { - // 由于重定向,这里不会执行到,但为了完整性添加 - setTimeout(() => { - setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false })); - }, 3000); - } - }; - - // 包装的邮箱登录选项点击处理 - const handleEmailLoginClick = () => { - setEmailLoginLoading(true); - setShowEmailLogin(true); - setEmailLoginLoading(false); - }; - - const handlePasskeyLogin = async () => { - if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { - showInfo(t('请先阅读并同意用户协议和隐私政策')); - return; - } - if (!passkeySupported) { - showInfo('当前环境无法使用 Passkey 登录'); - return; - } - if (!window.PublicKeyCredential) { - showInfo('当前浏览器不支持 Passkey'); - return; - } - - setPasskeyLoading(true); - try { - const beginRes = await API.post('/api/user/passkey/login/begin'); - const { success, message, data } = beginRes.data; - if (!success) { - showError(message || '无法发起 Passkey 登录'); - return; - } - - const publicKeyOptions = prepareCredentialRequestOptions( - data?.options || data?.publicKey || data, - ); - const assertion = await navigator.credentials.get({ - publicKey: publicKeyOptions, - }); - const payload = buildAssertionResult(assertion); - if (!payload) { - showError('Passkey 验证失败,请重试'); - return; - } - - const finishRes = await API.post( - '/api/user/passkey/login/finish', - payload, - ); - const finish = finishRes.data; - if (finish.success) { - userDispatch({ type: 'login', payload: finish.data }); - setUserData(finish.data); - updateAPI(); - showSuccess('登录成功!'); - navigate('/console'); - } else { - showError(finish.message || 'Passkey 登录失败,请重试'); - } - } catch (error) { - if (error?.name === 'AbortError') { - showInfo('已取消 Passkey 登录'); - } else { - showError('Passkey 登录失败,请重试'); - } - } finally { - setPasskeyLoading(false); - } - }; - - // 包装的重置密码点击处理 - const handleResetPasswordClick = () => { - setResetPasswordLoading(true); - navigate('/reset'); - setResetPasswordLoading(false); - }; - - // 包装的其他登录选项点击处理 - const handleOtherLoginOptionsClick = () => { - setOtherLoginOptionsLoading(true); - setShowEmailLogin(false); - setOtherLoginOptionsLoading(false); - }; - - // 2FA验证成功处理 - const handle2FASuccess = (data) => { - userDispatch({ type: 'login', payload: data }); - setUserData(data); - updateAPI(); - showSuccess('登录成功!'); - navigate('/console'); - }; - - // 返回登录页面 - const handleBackToLogin = () => { - setShowTwoFA(false); - setInputs({ username: '', password: '', wechat_verification_code: '' }); - }; - - const renderOAuthOptions = () => { - return ( -

-
-
- Logo - - {systemName} - -
- - -
- - {t('登 录')} - -
-
-
- {status.wechat_login && ( - - )} - - {status.github_oauth && ( - - )} - - {status.discord_oauth && ( - - )} - - {status.oidc_enabled && ( - - )} - - {status.linuxdo_oauth && ( - - )} - - {status.custom_oauth_providers && - status.custom_oauth_providers.map((provider) => ( - - ))} - - {status.telegram_oauth && ( -
- -
- )} - - {status.passkey_login && passkeySupported && ( - - )} - - - {t('或')} - - - -
- - {(hasUserAgreement || hasPrivacyPolicy) && ( -
- )} - - {!status.self_use_mode_enabled && ( -
- - {t('没有账户?')}{' '} - - {t('注册')} - - -
- )} -
- -
-
- ); - }; - - const renderEmailLoginForm = () => { - return ( -
-
-
- Logo - {systemName} -
- - -
- - {t('登 录')} - -
-
- {status.passkey_login && passkeySupported && ( - - )} - - handleChange('username', value)} - prefix={} - /> - - handleChange('password', value)} - prefix={} - /> - - {(hasUserAgreement || hasPrivacyPolicy) && ( -
- setAgreedToTerms(e.target.checked)} - > - - {t('我已阅读并同意')} - {hasUserAgreement && ( - <> - - {t('用户协议')} - - - )} - {hasUserAgreement && hasPrivacyPolicy && t('和')} - {hasPrivacyPolicy && ( - <> - - {t('隐私政策')} - - - )} - - -
- )} - -
- - - -
- - - {hasOAuthLoginOptions && ( - <> - - {t('或')} - - -
- -
- - )} - - {!status.self_use_mode_enabled && ( -
- - {t('没有账户?')}{' '} - - {t('注册')} - - -
- )} -
-
-
-
- ); - }; - - // 微信登录模态框 - const renderWeChatLoginModal = () => { - return ( - setShowWeChatLoginModal(false)} - okText={t('登录')} - centered={true} - okButtonProps={{ - loading: wechatCodeSubmitLoading, - }} - > -
- 微信二维码 -
- -
-

- {t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')} -

-
- -
- - handleChange('wechat_verification_code', value) - } - /> - -
- ); - }; - - // 2FA验证弹窗 - const render2FAModal = () => { - return ( - -
- - - -
- 两步验证 -
- } - visible={showTwoFA} - onCancel={handleBackToLogin} - footer={null} - width={450} - centered - > - - - ); - }; - - return ( -
- {/* 背景模糊晕染球 */} -
-
-
- {showEmailLogin || - !hasOAuthLoginOptions - ? renderEmailLoginForm() - : renderOAuthOptions()} - {renderWeChatLoginModal()} - {render2FAModal()} - - {turnstileEnabled && ( -
- { - setTurnstileToken(token); - }} - /> -
- )} -
-
- ); -}; - -export default LoginForm; diff --git a/web/classic/src/components/auth/OAuth2Callback.jsx b/web/classic/src/components/auth/OAuth2Callback.jsx deleted file mode 100644 index 55a85c6b14a5..000000000000 --- a/web/classic/src/components/auth/OAuth2Callback.jsx +++ /dev/null @@ -1,107 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React, { useContext, useEffect, useRef } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { - API, - showError, - showSuccess, - updateAPI, - setUserData, -} from '../../helpers'; -import { UserContext } from '../../context/User'; -import Loading from '../common/ui/Loading'; - -const OAuth2Callback = (props) => { - const { t } = useTranslation(); - const [searchParams] = useSearchParams(); - const [, userDispatch] = useContext(UserContext); - const navigate = useNavigate(); - - // 防止 React 18 Strict Mode 下重复执行 - const hasExecuted = useRef(false); - - // 最大重试次数 - const MAX_RETRIES = 3; - - const sendCode = async (code, state, retry = 0) => { - try { - const { data: resData } = await API.get( - `/api/oauth/${props.type}?code=${code}&state=${state}`, - ); - - const { success, message, data } = resData; - - if (!success) { - // 业务错误不重试,直接显示错误 - showError(message || t('授权失败')); - return; - } - - if (data?.action === 'bind') { - showSuccess(t('绑定成功!')); - navigate('/console/personal'); - } else { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - setUserData(data); - updateAPI(); - showSuccess(t('登录成功!')); - navigate('/console/token'); - } - } catch (error) { - // 网络错误等可重试 - if (retry < MAX_RETRIES) { - // 递增的退避等待 - await new Promise((resolve) => setTimeout(resolve, (retry + 1) * 2000)); - return sendCode(code, state, retry + 1); - } - - // 重试次数耗尽,提示错误并返回设置页面 - showError(error.message || t('授权失败')); - navigate('/console/personal'); - } - }; - - useEffect(() => { - // 防止 React 18 Strict Mode 下重复执行 - if (hasExecuted.current) { - return; - } - hasExecuted.current = true; - - const code = searchParams.get('code'); - const state = searchParams.get('state'); - - // 参数缺失直接返回 - if (!code) { - showError(t('未获取到授权码')); - navigate('/console/personal'); - return; - } - - sendCode(code, state); - }, []); - - return ; -}; - -export default OAuth2Callback; diff --git a/web/classic/src/components/auth/PasswordResetConfirm.jsx b/web/classic/src/components/auth/PasswordResetConfirm.jsx deleted file mode 100644 index d4f4b228627c..000000000000 --- a/web/classic/src/components/auth/PasswordResetConfirm.jsx +++ /dev/null @@ -1,220 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React, { useEffect, useState } from 'react'; -import { - API, - copy, - showError, - showNotice, - getLogo, - getSystemName, -} from '../../helpers'; -import { useSearchParams, Link } from 'react-router-dom'; -import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui'; -import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons'; -import { useTranslation } from 'react-i18next'; - -const { Text, Title } = Typography; - -const PasswordResetConfirm = () => { - const { t } = useTranslation(); - const [inputs, setInputs] = useState({ - email: '', - token: '', - }); - const { email, token } = inputs; - const isValidResetLink = email && token; - - const [loading, setLoading] = useState(false); - const [disableButton, setDisableButton] = useState(false); - const [countdown, setCountdown] = useState(30); - const [newPassword, setNewPassword] = useState(''); - const [searchParams, setSearchParams] = useSearchParams(); - const [formApi, setFormApi] = useState(null); - - const logo = getLogo(); - const systemName = getSystemName(); - - useEffect(() => { - let token = searchParams.get('token'); - let email = searchParams.get('email'); - setInputs({ - token: token || '', - email: email || '', - }); - if (formApi) { - formApi.setValues({ - email: email || '', - newPassword: newPassword || '', - }); - } - }, [searchParams, newPassword, formApi]); - - useEffect(() => { - let countdownInterval = null; - if (disableButton && countdown > 0) { - countdownInterval = setInterval(() => { - setCountdown(countdown - 1); - }, 1000); - } else if (countdown === 0) { - setDisableButton(false); - setCountdown(30); - } - return () => clearInterval(countdownInterval); - }, [disableButton, countdown]); - - async function handleSubmit(e) { - if (!email || !token) { - showError(t('无效的重置链接,请重新发起密码重置请求')); - return; - } - setDisableButton(true); - setLoading(true); - const res = await API.post(`/api/user/reset`, { - email, - token, - }); - const { success, message } = res.data; - if (success) { - let password = res.data.data; - setNewPassword(password); - await copy(password); - showNotice(`${t('密码已重置并已复制到剪贴板:')} ${password}`); - } else { - showError(message); - } - setLoading(false); - } - - return ( -
- {/* 背景模糊晕染球 */} -
-
-
-
-
-
- Logo - - {systemName} - -
- - -
- - {t('密码重置确认')} - -
-
- {!isValidResetLink && ( - - )} -
setFormApi(api)} - initValues={{ - email: email || '', - newPassword: newPassword || '', - }} - className='space-y-4' - > - } - placeholder={email ? '' : t('等待获取邮箱信息...')} - /> - - {newPassword && ( - } - suffix={ - - } - /> - )} - -
- -
- - -
- - - {t('返回登录')} - - -
-
-
-
-
-
-
- ); -}; - -export default PasswordResetConfirm; diff --git a/web/classic/src/components/auth/PasswordResetForm.jsx b/web/classic/src/components/auth/PasswordResetForm.jsx deleted file mode 100644 index 4ccc8a52c284..000000000000 --- a/web/classic/src/components/auth/PasswordResetForm.jsx +++ /dev/null @@ -1,193 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React, { useEffect, useState } from 'react'; -import { - API, - getLogo, - showError, - showInfo, - showSuccess, - getSystemName, -} from '../../helpers'; -import Turnstile from 'react-turnstile'; -import { Button, Card, Form, Typography } from '@douyinfe/semi-ui'; -import { IconMail } from '@douyinfe/semi-icons'; -import { Link } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; - -const { Text, Title } = Typography; - -const PasswordResetForm = () => { - const { t } = useTranslation(); - const [inputs, setInputs] = useState({ - email: '', - }); - const { email } = inputs; - - const [loading, setLoading] = useState(false); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - const [disableButton, setDisableButton] = useState(false); - const [countdown, setCountdown] = useState(30); - - const logo = getLogo(); - const systemName = getSystemName(); - - useEffect(() => { - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - if (status.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } - } - }, []); - - useEffect(() => { - let countdownInterval = null; - if (disableButton && countdown > 0) { - countdownInterval = setInterval(() => { - setCountdown(countdown - 1); - }, 1000); - } else if (countdown === 0) { - setDisableButton(false); - setCountdown(30); - } - return () => clearInterval(countdownInterval); - }, [disableButton, countdown]); - - function handleChange(value) { - setInputs((inputs) => ({ ...inputs, email: value })); - } - - async function handleSubmit(e) { - if (!email) { - showError(t('请输入邮箱地址')); - return; - } - if (turnstileEnabled && turnstileToken === '') { - showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!')); - return; - } - setDisableButton(true); - setLoading(true); - const res = await API.get( - `/api/reset_password?email=${email}&turnstile=${turnstileToken}`, - ); - const { success, message } = res.data; - if (success) { - showSuccess(t('重置邮件发送成功,请检查邮箱!')); - setInputs({ ...inputs, email: '' }); - } else { - showError(message); - } - setLoading(false); - } - - return ( -
- {/* 背景模糊晕染球 */} -
-
-
-
-
-
- Logo - - {systemName} - -
- - -
- - {t('密码重置')} - -
-
-
- } - /> - -
- -
- - -
- - {t('想起来了?')}{' '} - - {t('登录')} - - -
-
-
- - {turnstileEnabled && ( -
- { - setTurnstileToken(token); - }} - /> -
- )} -
-
-
-
- ); -}; - -export default PasswordResetForm; diff --git a/web/classic/src/components/auth/RegisterForm.jsx b/web/classic/src/components/auth/RegisterForm.jsx deleted file mode 100644 index 6838e4fd1eb4..000000000000 --- a/web/classic/src/components/auth/RegisterForm.jsx +++ /dev/null @@ -1,805 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { - API, - getLogo, - showError, - showInfo, - showSuccess, - updateAPI, - getSystemName, - getOAuthProviderIcon, - setUserData, - onDiscordOAuthClicked, - onCustomOAuthClicked, -} from '../../helpers'; -import Turnstile from 'react-turnstile'; -import { - Button, - Card, - Checkbox, - Divider, - Form, - Icon, - Modal, -} from '@douyinfe/semi-ui'; -import Title from '@douyinfe/semi-ui/lib/es/typography/title'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; -import { - IconGithubLogo, - IconMail, - IconUser, - IconLock, - IconKey, -} from '@douyinfe/semi-icons'; -import { - onGitHubOAuthClicked, - onLinuxDOOAuthClicked, - onOIDCClicked, -} from '../../helpers'; -import OIDCIcon from '../common/logo/OIDCIcon'; -import LinuxDoIcon from '../common/logo/LinuxDoIcon'; -import WeChatIcon from '../common/logo/WeChatIcon'; -import TelegramLoginButton from 'react-telegram-login/src'; -import { UserContext } from '../../context/User'; -import { StatusContext } from '../../context/Status'; -import { useTranslation } from 'react-i18next'; -import { SiDiscord } from 'react-icons/si'; - -const RegisterForm = () => { - let navigate = useNavigate(); - const { t } = useTranslation(); - const githubButtonTextKeyByState = { - idle: '使用 GitHub 继续', - redirecting: '正在跳转 GitHub...', - timeout: '请求超时,请刷新页面后重新发起 GitHub 登录', - }; - const [inputs, setInputs] = useState({ - username: '', - password: '', - password2: '', - email: '', - verification_code: '', - wechat_verification_code: '', - }); - const { username, password, password2 } = inputs; - const [userState, userDispatch] = useContext(UserContext); - const [statusState] = useContext(StatusContext); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); - const [showEmailRegister, setShowEmailRegister] = useState(false); - const [wechatLoading, setWechatLoading] = useState(false); - const [githubLoading, setGithubLoading] = useState(false); - const [discordLoading, setDiscordLoading] = useState(false); - const [oidcLoading, setOidcLoading] = useState(false); - const [linuxdoLoading, setLinuxdoLoading] = useState(false); - const [emailRegisterLoading, setEmailRegisterLoading] = useState(false); - const [registerLoading, setRegisterLoading] = useState(false); - const [verificationCodeLoading, setVerificationCodeLoading] = useState(false); - const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = - useState(false); - const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); - const [customOAuthLoading, setCustomOAuthLoading] = useState({}); - const [disableButton, setDisableButton] = useState(false); - const [countdown, setCountdown] = useState(30); - const [agreedToTerms, setAgreedToTerms] = useState(false); - const [hasUserAgreement, setHasUserAgreement] = useState(false); - const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false); - const [githubButtonState, setGithubButtonState] = useState('idle'); - const [githubButtonDisabled, setGithubButtonDisabled] = useState(false); - const githubTimeoutRef = useRef(null); - const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]); - - const logo = getLogo(); - const systemName = getSystemName(); - - let affCode = new URLSearchParams(window.location.search).get('aff'); - if (affCode) { - localStorage.setItem('aff', affCode); - } - - const status = useMemo(() => { - if (statusState?.status) return statusState.status; - const savedStatus = localStorage.getItem('status'); - if (!savedStatus) return {}; - try { - return JSON.parse(savedStatus) || {}; - } catch (err) { - return {}; - } - }, [statusState?.status]); - const hasCustomOAuthProviders = - (status.custom_oauth_providers || []).length > 0; - const hasOAuthRegisterOptions = Boolean( - status.github_oauth || - status.discord_oauth || - status.oidc_enabled || - status.wechat_login || - status.linuxdo_oauth || - status.telegram_oauth || - hasCustomOAuthProviders, - ); - - const [showEmailVerification, setShowEmailVerification] = useState(false); - - useEffect(() => { - setShowEmailVerification(!!status?.email_verification); - if (status?.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } - - // 从 status 获取用户协议和隐私政策的启用状态 - setHasUserAgreement(status?.user_agreement_enabled || false); - setHasPrivacyPolicy(status?.privacy_policy_enabled || false); - }, [status]); - - useEffect(() => { - let countdownInterval = null; - if (disableButton && countdown > 0) { - countdownInterval = setInterval(() => { - setCountdown(countdown - 1); - }, 1000); - } else if (countdown === 0) { - setDisableButton(false); - setCountdown(30); - } - return () => clearInterval(countdownInterval); // Clean up on unmount - }, [disableButton, countdown]); - - useEffect(() => { - return () => { - if (githubTimeoutRef.current) { - clearTimeout(githubTimeoutRef.current); - } - }; - }, []); - - const onWeChatLoginClicked = () => { - setWechatLoading(true); - setShowWeChatLoginModal(true); - setWechatLoading(false); - }; - - const onSubmitWeChatVerificationCode = async () => { - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setWechatCodeSubmitLoading(true); - try { - const res = await API.get( - `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, - ); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - setUserData(data); - updateAPI(); - navigate('/'); - showSuccess('登录成功!'); - setShowWeChatLoginModal(false); - } else { - showError(message); - } - } catch (error) { - showError('登录失败,请重试'); - } finally { - setWechatCodeSubmitLoading(false); - } - }; - - function handleChange(name, value) { - setInputs((inputs) => ({ ...inputs, [name]: value })); - } - - async function handleSubmit(e) { - if (password.length < 8) { - showInfo('密码长度不得小于 8 位!'); - return; - } - if (password !== password2) { - showInfo('两次输入的密码不一致'); - return; - } - if (username && password) { - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setRegisterLoading(true); - try { - if (!affCode) { - affCode = localStorage.getItem('aff'); - } - inputs.aff_code = affCode; - const res = await API.post( - `/api/user/register?turnstile=${turnstileToken}`, - inputs, - ); - const { success, message } = res.data; - if (success) { - navigate('/login'); - showSuccess('注册成功!'); - } else { - showError(message); - } - } catch (error) { - showError('注册失败,请重试'); - } finally { - setRegisterLoading(false); - } - } - } - - const sendVerificationCode = async () => { - if (inputs.email === '') return; - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setVerificationCodeLoading(true); - try { - const res = await API.get( - `/api/verification?email=${encodeURIComponent(inputs.email)}&turnstile=${turnstileToken}`, - ); - const { success, message } = res.data; - if (success) { - showSuccess('验证码发送成功,请检查你的邮箱!'); - setDisableButton(true); // 发送成功后禁用按钮,开始倒计时 - } else { - showError(message); - } - } catch (error) { - showError('发送验证码失败,请重试'); - } finally { - setVerificationCodeLoading(false); - } - }; - - const handleGitHubClick = () => { - if (githubButtonDisabled) { - return; - } - setGithubLoading(true); - setGithubButtonDisabled(true); - setGithubButtonState('redirecting'); - if (githubTimeoutRef.current) { - clearTimeout(githubTimeoutRef.current); - } - githubTimeoutRef.current = setTimeout(() => { - setGithubLoading(false); - setGithubButtonState('timeout'); - setGithubButtonDisabled(true); - }, 20000); - try { - onGitHubOAuthClicked(status.github_client_id, { shouldLogout: true }); - } finally { - setTimeout(() => setGithubLoading(false), 3000); - } - }; - - const handleDiscordClick = () => { - setDiscordLoading(true); - try { - onDiscordOAuthClicked(status.discord_client_id, { shouldLogout: true }); - } finally { - setTimeout(() => setDiscordLoading(false), 3000); - } - }; - - const handleOIDCClick = () => { - setOidcLoading(true); - try { - onOIDCClicked( - status.oidc_authorization_endpoint, - status.oidc_client_id, - false, - { shouldLogout: true }, - ); - } finally { - setTimeout(() => setOidcLoading(false), 3000); - } - }; - - const handleLinuxDOClick = () => { - setLinuxdoLoading(true); - try { - onLinuxDOOAuthClicked(status.linuxdo_client_id, { shouldLogout: true }); - } finally { - setTimeout(() => setLinuxdoLoading(false), 3000); - } - }; - - const handleCustomOAuthClick = (provider) => { - setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true })); - try { - onCustomOAuthClicked(provider, { shouldLogout: true }); - } finally { - setTimeout(() => { - setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false })); - }, 3000); - } - }; - - const handleEmailRegisterClick = () => { - setEmailRegisterLoading(true); - setShowEmailRegister(true); - setEmailRegisterLoading(false); - }; - - const handleOtherRegisterOptionsClick = () => { - setOtherRegisterOptionsLoading(true); - setShowEmailRegister(false); - setOtherRegisterOptionsLoading(false); - }; - - const onTelegramLoginClicked = async (response) => { - const fields = [ - 'id', - 'first_name', - 'last_name', - 'username', - 'photo_url', - 'auth_date', - 'hash', - 'lang', - ]; - const params = {}; - fields.forEach((field) => { - if (response[field]) { - params[field] = response[field]; - } - }); - try { - const res = await API.get(`/api/oauth/telegram/login`, { params }); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - showSuccess('登录成功!'); - setUserData(data); - updateAPI(); - navigate('/'); - } else { - showError(message); - } - } catch (error) { - showError('登录失败,请重试'); - } - }; - - const renderOAuthOptions = () => { - return ( -
-
-
- Logo - - {systemName} - -
- - -
- - {t('注 册')} - -
-
-
- {status.wechat_login && ( - - )} - - {status.github_oauth && ( - - )} - - {status.discord_oauth && ( - - )} - - {status.oidc_enabled && ( - - )} - - {status.linuxdo_oauth && ( - - )} - - {status.custom_oauth_providers && - status.custom_oauth_providers.map((provider) => ( - - ))} - - {status.telegram_oauth && ( -
- -
- )} - - - {t('或')} - - - -
- -
- - {t('已有账户?')}{' '} - - {t('登录')} - - -
-
-
-
-
- ); - }; - - const renderEmailRegisterForm = () => { - return ( -
-
-
- Logo - - {systemName} - -
- - -
- - {t('注 册')} - -
-
-
- handleChange('username', value)} - prefix={} - /> - - handleChange('password', value)} - prefix={} - /> - - handleChange('password2', value)} - prefix={} - /> - - {showEmailVerification && ( - <> - handleChange('email', value)} - prefix={} - suffix={ - - } - /> - - handleChange('verification_code', value) - } - prefix={} - /> - - )} - - {(hasUserAgreement || hasPrivacyPolicy) && ( -
- setAgreedToTerms(e.target.checked)} - > - - {t('我已阅读并同意')} - {hasUserAgreement && ( - <> - - {t('用户协议')} - - - )} - {hasUserAgreement && hasPrivacyPolicy && t('和')} - {hasPrivacyPolicy && ( - <> - - {t('隐私政策')} - - - )} - - -
- )} - -
- -
- - - {hasOAuthRegisterOptions && ( - <> - - {t('或')} - - -
- -
- - )} - -
- - {t('已有账户?')}{' '} - - {t('登录')} - - -
-
-
-
-
- ); - }; - - const renderWeChatLoginModal = () => { - return ( - setShowWeChatLoginModal(false)} - okText={t('登录')} - centered={true} - okButtonProps={{ - loading: wechatCodeSubmitLoading, - }} - > -
- 微信二维码 -
- -
-

- {t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')} -

-
- -
- - handleChange('wechat_verification_code', value) - } - /> - -
- ); - }; - - return ( -
- {/* 背景模糊晕染球 */} -
-
-
- {showEmailRegister || - !hasOAuthRegisterOptions - ? renderEmailRegisterForm() - : renderOAuthOptions()} - {renderWeChatLoginModal()} - - {turnstileEnabled && ( -
- { - setTurnstileToken(token); - }} - /> -
- )} -
-
- ); -}; - -export default RegisterForm; diff --git a/web/classic/src/components/auth/TwoFAVerification.jsx b/web/classic/src/components/auth/TwoFAVerification.jsx deleted file mode 100644 index 626de74363be..000000000000 --- a/web/classic/src/components/auth/TwoFAVerification.jsx +++ /dev/null @@ -1,244 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ -import { API, showError, showSuccess } from '../../helpers'; -import { - Button, - Card, - Divider, - Form, - Input, - Typography, -} from '@douyinfe/semi-ui'; -import React, { useState } from 'react'; - -const { Title, Text, Paragraph } = Typography; - -const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => { - const [loading, setLoading] = useState(false); - const [useBackupCode, setUseBackupCode] = useState(false); - const [verificationCode, setVerificationCode] = useState(''); - - const handleSubmit = async () => { - if (!verificationCode) { - showError('请输入验证码'); - return; - } - // Validate code format - if (useBackupCode && verificationCode.length !== 8) { - showError('备用码必须是8位'); - return; - } else if (!useBackupCode && !/^\d{6}$/.test(verificationCode)) { - showError('验证码必须是6位数字'); - return; - } - - setLoading(true); - try { - const res = await API.post('/api/user/login/2fa', { - code: verificationCode, - }); - - if (res.data.success) { - showSuccess('登录成功'); - // 保存用户信息到本地存储 - localStorage.setItem('user', JSON.stringify(res.data.data)); - if (onSuccess) { - onSuccess(res.data.data); - } - } else { - showError(res.data.message); - } - } catch (error) { - showError('验证失败,请重试'); - } finally { - setLoading(false); - } - }; - - const handleKeyPress = (e) => { - if (e.key === 'Enter') { - handleSubmit(); - } - }; - - if (isModal) { - return ( -
- - 请输入认证器应用显示的验证码完成登录 - - -
- - - - - - - -
- - - {onBack && ( - - )} -
- -
- - 提示: -
- • 验证码每30秒更新一次 -
- • 如果无法获取验证码,请使用备用码 -
• 每个备用码只能使用一次 -
-
-
- ); - } - - return ( -
- -
- 两步验证 - - 请输入认证器应用显示的验证码完成登录 - -
- -
- - - - - - - -
- - - {onBack && ( - - )} -
- -
- - 提示: -
- • 验证码每30秒更新一次 -
- • 如果无法获取验证码,请使用备用码 -
• 每个备用码只能使用一次 -
-
-
-
- ); -}; - -export default TwoFAVerification; diff --git a/web/classic/src/components/common/DocumentRenderer/index.jsx b/web/classic/src/components/common/DocumentRenderer/index.jsx deleted file mode 100644 index 3325b2feb8ae..000000000000 --- a/web/classic/src/components/common/DocumentRenderer/index.jsx +++ /dev/null @@ -1,232 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React, { useEffect, useMemo, useState } from 'react'; -import { API, showError } from '../../../helpers'; -import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui'; -const { Title } = Typography; -import { - IllustrationConstruction, - IllustrationConstructionDark, -} from '@douyinfe/semi-illustrations'; -import { useTranslation } from 'react-i18next'; -import MarkdownRenderer from '../markdown/MarkdownRenderer'; - -// Check whether content is a URL. -const isUrl = (content) => { - try { - new URL(content.trim()); - return true; - } catch { - return false; - } -}; - -// Check whether content contains HTML. -const isHtmlContent = (content) => { - if (!content || typeof content !== 'string') return false; - - const htmlTagRegex = /<\/?[a-z][\s\S]*>/i; - return htmlTagRegex.test(content); -}; - -// Parse HTML content and extract inline styles. -const sanitizeHtml = (html) => { - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = html; - - const styles = Array.from(tempDiv.querySelectorAll('style')) - .map((style) => style.innerHTML) - .join('\n'); - - const bodyContent = tempDiv.querySelector('body'); - const content = bodyContent ? bodyContent.innerHTML : html; - - return { content, styles }; -}; - -/** - * 通用文档渲染组件 - * @param {string} apiEndpoint - API 接口地址 - * @param {string} title - 文档标题 - * @param {string} cacheKey - 本地存储缓存键 - * @param {string} emptyMessage - 空内容时的提示消息 - */ -const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { - const { t } = useTranslation(); - const [content, setContent] = useState(''); - const [loading, setLoading] = useState(true); - - const loadContent = async () => { - const cachedContent = localStorage.getItem(cacheKey) || ''; - if (cachedContent) { - setContent(cachedContent); - setLoading(false); - } - - try { - const res = await API.get(apiEndpoint); - const { success, message, data } = res.data; - if (success && data) { - setContent(data); - localStorage.setItem(cacheKey, data); - } else { - if (!cachedContent) { - showError(message || emptyMessage); - setContent(''); - } - } - } catch (error) { - if (!cachedContent) { - showError(emptyMessage); - setContent(''); - } - } finally { - setLoading(false); - } - }; - - const htmlPayload = useMemo(() => { - if (!isHtmlContent(content)) { - return { content: '', styles: '' }; - } - return sanitizeHtml(content); - }, [content]); - - useEffect(() => { - loadContent(); - }, []); - - // 处理HTML样式注入 - useEffect(() => { - const styleId = `document-renderer-styles-${cacheKey}`; - const { styles } = htmlPayload; - - if (styles) { - let styleEl = document.getElementById(styleId); - if (!styleEl) { - styleEl = document.createElement('style'); - styleEl.id = styleId; - styleEl.type = 'text/css'; - document.head.appendChild(styleEl); - } - styleEl.innerHTML = styles; - } else { - const el = document.getElementById(styleId); - if (el) el.remove(); - } - - return () => { - const el = document.getElementById(styleId); - if (el) el.remove(); - }; - }, [cacheKey, htmlPayload]); - - // 显示加载状态 - if (loading) { - return ( -
- -
- ); - } - - // 如果没有内容,显示空状态 - if (!content || content.trim() === '') { - return ( -
- - } - darkModeImage={ - - } - className='p-8' - /> -
- ); - } - - // 如果是 URL,显示链接卡片 - if (isUrl(content)) { - return ( -
- -
- - {title} - -

- {t('管理员设置了外部链接,点击下方按钮访问')} -

- - {t('访问' + title)} - -
-
-
- ); - } - - // 如果是 HTML 内容,直接渲染 - if (isHtmlContent(content)) { - return ( -
-
-
- - {title} - -
-
-
-
- ); - } - - // 其他内容统一使用 Markdown 渲染器 - return ( -
-
-
- - {title} - -
- -
-
-
-
- ); -}; - -export default DocumentRenderer; diff --git a/web/classic/src/components/common/ErrorBoundary.jsx b/web/classic/src/components/common/ErrorBoundary.jsx deleted file mode 100644 index 3827969acf95..000000000000 --- a/web/classic/src/components/common/ErrorBoundary.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import { Empty, Button } from '@douyinfe/semi-ui'; -import { - IllustrationFailure, - IllustrationFailureDark, -} from '@douyinfe/semi-illustrations'; -import { withTranslation } from 'react-i18next'; - -class ErrorBoundary extends React.Component { - constructor(props) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - componentDidCatch(error, errorInfo) { - console.error('[ErrorBoundary]', error, errorInfo); - } - - render() { - if (this.state.hasError) { - const { t } = this.props; - return ( -
- - } - darkModeImage={ - - } - description={t('页面渲染出错,请刷新页面重试')} - /> - -
- ); - } - return this.props.children; - } -} - -export default withTranslation()(ErrorBoundary); diff --git a/web/classic/src/components/common/examples/ChannelKeyViewExample.jsx b/web/classic/src/components/common/examples/ChannelKeyViewExample.jsx deleted file mode 100644 index 1bb2998b208b..000000000000 --- a/web/classic/src/components/common/examples/ChannelKeyViewExample.jsx +++ /dev/null @@ -1,113 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React, { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button, Modal } from '@douyinfe/semi-ui'; -import { useSecureVerification } from '../../../hooks/common/useSecureVerification'; -import { createApiCalls } from '../../../services/secureVerification'; -import SecureVerificationModal from '../modals/SecureVerificationModal'; -import ChannelKeyDisplay from '../ui/ChannelKeyDisplay'; - -/** - * 渠道密钥查看组件使用示例 - * 展示如何使用通用安全验证系统 - */ -const ChannelKeyViewExample = ({ channelId }) => { - const { t } = useTranslation(); - const [keyData, setKeyData] = useState(''); - const [showKeyModal, setShowKeyModal] = useState(false); - - // 使用通用安全验证 Hook - const { - isModalVisible, - verificationMethods, - verificationState, - startVerification, - executeVerification, - cancelVerification, - setVerificationCode, - switchVerificationMethod, - } = useSecureVerification({ - onSuccess: (result) => { - // 验证成功后处理结果 - if (result.success && result.data?.key) { - setKeyData(result.data.key); - setShowKeyModal(true); - } - }, - successMessage: t('密钥获取成功'), - }); - - // 开始查看密钥流程 - const handleViewKey = async () => { - const apiCall = createApiCalls.viewChannelKey(channelId); - - await startVerification(apiCall, { - title: t('查看渠道密钥'), - description: t('为了保护账户安全,请验证您的身份。'), - preferredMethod: 'passkey', // 可以指定首选验证方式 - }); - }; - - return ( - <> - {/* 查看密钥按钮 */} - - - {/* 安全验证模态框 */} - - - {/* 密钥显示模态框 */} - setShowKeyModal(false)} - footer={ - - } - width={700} - style={{ maxWidth: '90vw' }} - > - - - - ); -}; - -export default ChannelKeyViewExample; diff --git a/web/classic/src/components/common/logo/LinuxDoIcon.jsx b/web/classic/src/components/common/logo/LinuxDoIcon.jsx deleted file mode 100644 index 861f19d4f204..000000000000 --- a/web/classic/src/components/common/logo/LinuxDoIcon.jsx +++ /dev/null @@ -1,56 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React from 'react'; -import { Icon } from '@douyinfe/semi-ui'; - -const LinuxDoIcon = (props) => { - function CustomIcon() { - return ( - - - - - - - - ); - } - - return } />; -}; - -export default LinuxDoIcon; diff --git a/web/classic/src/components/common/logo/OIDCIcon.jsx b/web/classic/src/components/common/logo/OIDCIcon.jsx deleted file mode 100644 index 28d538eb060d..000000000000 --- a/web/classic/src/components/common/logo/OIDCIcon.jsx +++ /dev/null @@ -1,57 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React from 'react'; -import { Icon } from '@douyinfe/semi-ui'; - -const OIDCIcon = (props) => { - function CustomIcon() { - return ( - - - - - ); - } - - return } />; -}; - -export default OIDCIcon; diff --git a/web/classic/src/components/common/logo/WeChatIcon.jsx b/web/classic/src/components/common/logo/WeChatIcon.jsx deleted file mode 100644 index f9f7057cf932..000000000000 --- a/web/classic/src/components/common/logo/WeChatIcon.jsx +++ /dev/null @@ -1,55 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React from 'react'; -import { Icon } from '@douyinfe/semi-ui'; - -const WeChatIcon = () => { - function CustomIcon() { - return ( - - - - - ); - } - - return ( -
- } /> -
- ); -}; - -export default WeChatIcon; diff --git a/web/classic/src/components/common/markdown/MarkdownRenderer.jsx b/web/classic/src/components/common/markdown/MarkdownRenderer.jsx deleted file mode 100644 index 6a71c695f845..000000000000 --- a/web/classic/src/components/common/markdown/MarkdownRenderer.jsx +++ /dev/null @@ -1,697 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import ReactMarkdown from 'react-markdown'; -import 'katex/dist/katex.min.css'; -import 'highlight.js/styles/github.css'; -import './markdown.css'; -import RemarkMath from 'remark-math'; -import RemarkBreaks from 'remark-breaks'; -import RehypeKatex from 'rehype-katex'; -import RemarkGfm from 'remark-gfm'; -import RehypeHighlight from 'rehype-highlight'; -import { useRef, useState, useEffect, useMemo } from 'react'; -import mermaid from 'mermaid'; -import React from 'react'; -import { useDebouncedCallback } from 'use-debounce'; -import clsx from 'clsx'; -import { Button, Tooltip, Toast } from '@douyinfe/semi-ui'; -import { copy, rehypeSplitWordsIntoSpans } from '../../../helpers'; -import { IconCopy } from '@douyinfe/semi-icons'; -import { useTranslation } from 'react-i18next'; - -mermaid.initialize({ - startOnLoad: false, - theme: 'default', - securityLevel: 'loose', -}); - -export function Mermaid(props) { - const ref = useRef(null); - const [hasError, setHasError] = useState(false); - - useEffect(() => { - if (props.code && ref.current) { - mermaid - .run({ - nodes: [ref.current], - suppressErrors: true, - }) - .catch((e) => { - setHasError(true); - console.error('[Mermaid] ', e.message); - }); - } - }, [props.code]); - - function viewSvgInNewWindow() { - const svg = ref.current?.querySelector('svg'); - if (!svg) return; - const text = new XMLSerializer().serializeToString(svg); - const blob = new Blob([text], { type: 'image/svg+xml' }); - const url = URL.createObjectURL(blob); - window.open(url, '_blank'); - } - - if (hasError) { - return null; - } - - return ( -
viewSvgInNewWindow()} - > - {props.code} -
- ); -} - -function SandboxedHtmlPreview({ code }) { - const iframeRef = useRef(null); - const [iframeHeight, setIframeHeight] = useState(150); - - useEffect(() => { - const iframe = iframeRef.current; - if (!iframe) return; - - const handleLoad = () => { - try { - const doc = iframe.contentDocument || iframe.contentWindow?.document; - if (doc) { - const height = - doc.documentElement.scrollHeight || doc.body.scrollHeight; - setIframeHeight(Math.min(Math.max(height + 16, 60), 600)); - } - } catch { - // sandbox restrictions may prevent access, that's fine - } - }; - - iframe.addEventListener('load', handleLoad); - return () => iframe.removeEventListener('load', handleLoad); - }, [code]); - - return ( -